diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef3c17..814d29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `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) +* a Sort By button after Search in each section, to sort notes and folders by name (A to Z or Z to A) or newest first by date modified or created, saved in the new `notes.sortOrder` setting; folders stay first, and List Notes uses the same order after the recently opened notes (#22, #63) ### Changed +* Refresh and Settings moved from the title bar of Notes and Workspace Notes to its `...` menu * notes open like they do from the Explorer, with the editor VS Code associates with their file type: images open in the image viewer, and Markdown opens in the preview or another Markdown editor when `workbench.editorAssociations` says so (#53, #70); a new note still opens as text ### Fixed diff --git a/README.md b/README.md index db686d6..673c6d4 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,14 @@ The extension can be accessed using the Notes icon that is placed in the Activit * 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, 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. +* sort notes by name or by date modified or created with the Sort By button at the top of Notes. * 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. * 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. ## Getting Started -Notes will prompt you for a storage location the first time you access the extension from the Activity Bar or through the Command Palette. If you would like to change the storage location, later on, you can access the Notes extension settings by clicking on the gear icon in Notes or from the Command Palette. After you've selected a storage location, you can access your notes from the Notes icon in the Activity Bar, or through the Command Palette. +Notes will prompt you for a storage location the first time you access the extension from the Activity Bar or through the Command Palette. If you would like to change the storage location, later on, you can access the Notes extension settings from **Settings** in the `...` menu at the top of Notes, or from the Command Palette. After you've selected a storage location, you can access your notes from the Notes icon in the Activity Bar, or through the Command Palette. ## Workspace Notes @@ -44,6 +45,7 @@ This extension contributes the following settings: * `notes.notesLocation`: location of the notes in the Notes section, set in your user settings and not synced between machines. In a [portable](https://code.visualstudio.com/docs/editor/portable) VS Code, a relative path is relative to the folder holding VS Code, so the notes can travel with it (for example `data/Notes` on Windows and Linux) * `notes.workspaceNotesLocation`: location of the notes in the Workspace Notes section, relative to the workspace folder or a full path * `notes.notesDefaultNoteExtension`: extension used for new notes that don't have one in their name +* `notes.sortOrder`: how notes and folders are sorted, by name (A to Z or Z to A) or newest first by date modified or created, also set with the Sort By button at the top of each section * `notes.notesExtensions`: list of extensions recognized as notes or '*' for all extensions * `notes.watchExternalChanges`: update the Notes view when notes are added, renamed or removed outside of VS Code * `notes.watchIntervalSeconds`: how often the open folders are checked for those changes while the Notes view is visible diff --git a/package.json b/package.json index 3185963..4ebde25 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,23 @@ "default": "*", "description": "A comma separated list of allowed extensions for notes. Use '*' (asterisk) to allow all file types. (do not include the dot or spaces)" }, + "notes.sortOrder": { + "type": "string", + "default": "name", + "enum": [ + "name", + "nameDescending", + "modified", + "created" + ], + "enumDescriptions": [ + "By name, A to Z.", + "By name, Z to A.", + "By date modified, newest first.", + "By date created, newest first. Where the file system does not record it, the date modified is used." + ], + "description": "How notes and folders are sorted in Notes and Workspace Notes, and the notes after the recently opened ones in List Notes. Folders always come first." + }, "notes.watchExternalChanges": { "type": "boolean", "default": true, @@ -156,6 +173,12 @@ "title": "Search in Folder", "category": "Notes" }, + { + "command": "Notes.sortBy", + "title": "Sort By...", + "category": "Notes", + "icon": "$(sort-precedence)" + }, { "command": "Notes.refreshNotes", "title": "Refresh Notes", @@ -260,10 +283,15 @@ "group": "navigation@3" }, { - "command": "Notes.refreshNotes", + "command": "Notes.sortBy", "when": "view == workspaceNotes", "group": "navigation@4" }, + { + "command": "Notes.refreshNotes", + "when": "view == workspaceNotes", + "group": "3_view@1" + }, { "command": "Notes.revealWorkspaceNotesInOS", "when": "view == workspaceNotes && (!remoteName || remoteName == wsl)", @@ -290,14 +318,19 @@ "group": "navigation@3" }, { - "command": "Notes.refreshNotes", + "command": "Notes.sortBy", "when": "view == notes", "group": "navigation@4" }, + { + "command": "Notes.refreshNotes", + "when": "view == notes", + "group": "3_view@1" + }, { "command": "Notes.setupNotes", "when": "view == notes", - "group": "navigation@5" + "group": "4_settings@1" }, { "command": "Notes.revealInOS", diff --git a/src/extension.ts b/src/extension.ts index 5d9bc7a..908a96e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import { NotesViewProvider } from './notesViewProvider'; import { NotesWatcher } from './notesWatcher'; import { getLanguageExtensions, isMarkdownFile, resetLanguageExtensions } from './languages'; import { RecentNotes } from './recentNotes'; +import { compareEntries, readSortEntry, SortOrder, toSortOrder } from './sortOrder'; import { MovableItem, NotesDragAndDrop } from './notesDragAndDrop'; import { getLocationState, getMoveDestination, getPortableFolder, isInside, isNoteName, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from './location'; @@ -84,7 +85,8 @@ export function activate(context: vscode.ExtensionContext) { // apply a new storage location or list of extensions right away if (e.affectsConfiguration('notes.notesLocation') || e.affectsConfiguration('notes.workspaceNotesLocation') - || e.affectsConfiguration('notes.notesExtensions')) { + || e.affectsConfiguration('notes.notesExtensions') + || e.affectsConfiguration('notes.sortOrder')) { updateTrees(true); } // apply new watch settings @@ -142,6 +144,12 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push(listNotesDisposable); + // choose how notes are sorted + let sortByDisposable = vscode.commands.registerCommand('Notes.sortBy', () => { + Notes.pickSortOrder(); + }); + context.subscriptions.push(sortByDisposable); + // forget the recently opened notes let clearRecentNotesDisposable = vscode.commands.registerCommand('Notes.clearRecentlyOpened', async () => { await recentNotes.clear(); @@ -486,10 +494,16 @@ export class Notes { const recent = recentNotes.get() .map(filePath => notes.find(note => note.filePath === filePath)) .filter((note): note is NoteItem => !!note); + // the other notes in the order chosen for the tree; by name they stay grouped by folder + const order = toSortOrder(vscode.workspace.getConfiguration('notes').get('sortOrder')); const others = notes.filter(note => !recent.includes(note)); + if (order !== 'name') { + const entries = new Map(others.map(note => [note, readSortEntry(note.filePath!, false, order)])); + others.sort((a, b) => compareEntries(entries.get(a)!, entries.get(b)!, order)); + } const listed: NoteItem[] = recent.length ? [{ label: 'recently opened', kind: vscode.QuickPickItemKind.Separator }, ...recent, { label: 'other notes', kind: vscode.QuickPickItemKind.Separator }, ...others] - : notes; + : others; const quickPick = vscode.window.createQuickPick(); quickPick.placeholder = 'Open a note, or type a name to create one'; @@ -522,6 +536,46 @@ export class Notes { quickPick.show(); } + // choose how notes and folders are sorted, saved in the notes.sortOrder setting + static pickSortOrder(): void { + const config = vscode.workspace.getConfiguration('notes'); + const current = toSortOrder(config.get('sortOrder')); + const options: { order: SortOrder, label: string, description: string }[] = [ + { order: 'name', label: 'Name', description: 'A to Z' }, + { order: 'nameDescending', label: 'Name', description: 'Z to A' }, + { order: 'modified', label: 'Date Modified', description: 'newest first' }, + { order: 'created', label: 'Date Created', description: 'newest first' }, + ]; + type SortItem = vscode.QuickPickItem & { order: SortOrder }; + // menus can't show a check mark for an extension, the list marks the current order instead + const items: SortItem[] = options.map(option => ({ + label: `${option.order === current ? '$(check)' : '$(blank)'} ${option.label}`, + description: option.description, + order: option.order + })); + + const quickPick = vscode.window.createQuickPick(); + quickPick.placeholder = 'Sort notes by'; + quickPick.items = items; + quickPick.activeItems = items.filter(item => item.order === current); + quickPick.onDidAccept(() => { + const picked = quickPick.activeItems[0]; + quickPick.hide(); + if (picked && picked.order !== current) { + // where the setting is in effect, the workspace settings when they set it + const target = config.inspect('sortOrder')?.workspaceValue !== undefined + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + config.update('sortOrder', picked.order, target).then(undefined, err => { + console.error(err); + vscode.window.showErrorMessage('Failed to save the sort order.'); + }); + } + }); + quickPick.onDidHide(() => quickPick.dispose()); + quickPick.show(); + } + // new note static async newNote(tree: Refreshable, kind?: NotesKind, item?: Note): Promise { // Determine the location where the note should be created diff --git a/src/notesViewProvider.ts b/src/notesViewProvider.ts index 4dd7d4b..3ef05ec 100644 --- a/src/notesViewProvider.ts +++ b/src/notesViewProvider.ts @@ -4,6 +4,7 @@ import * as gl from 'glob'; import * as path from 'path'; import { Note } from './note'; import { LocationState, getLocationState } from './location'; +import { compareEntries, readSortEntry, toSortOrder } from './sortOrder'; export class NotesViewProvider implements vscode.TreeDataProvider { @@ -169,16 +170,10 @@ export class NotesViewProvider implements vscode.TreeDataProvider { console.error('Error reading directory:', err); } - // Sort: folders first, then notes alphabetically - result.sort((a, b) => { - if (a.isFolder && !b.isFolder) { - return -1; - } - if (!a.isFolder && b.isFolder) { - return 1; - } - return a.name.localeCompare(b.name); - }); + // Sort: folders first, then by the notes.sortOrder setting + const order = toSortOrder(vscode.workspace.getConfiguration('notes').get('sortOrder')); + const entries = new Map(result.map(note => [note, readSortEntry(note.fullPath, note.isFolder, order)])); + result.sort((a, b) => compareEntries(entries.get(a)!, entries.get(b)!, order)); return result; } diff --git a/src/sortOrder.ts b/src/sortOrder.ts new file mode 100644 index 0000000..93447c9 --- /dev/null +++ b/src/sortOrder.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +// how notes and folders are ordered: by name, A to Z or Z to A, or newest first by date modified or created; +// folders always come first, as in the Explorer +export type SortOrder = 'name' | 'nameDescending' | 'modified' | 'created'; + +const SORT_ORDERS: SortOrder[] = ['name', 'nameDescending', 'modified', 'created']; + +// what the order compares, dates in milliseconds +export interface SortEntry { + name: string; + isFolder: boolean; + modified: number; + created: number; +} + +// the notes.sortOrder setting, name for anything it doesn't know +export function toSortOrder(value: unknown): SortOrder { + return SORT_ORDERS.includes(value as SortOrder) ? value as SortOrder : 'name'; +} + +// does this order need the dates of files and folders? +export function needsDates(order: SortOrder): boolean { + return order === 'modified' || order === 'created'; +} + +export function compareEntries(a: SortEntry, b: SortEntry, order: SortOrder): number { + if (a.isFolder !== b.isFolder) { + return a.isFolder ? -1 : 1; + } + switch (order) { + case 'nameDescending': + return b.name.localeCompare(a.name); + // newest first, then by name for the same date + case 'modified': + return b.modified - a.modified || a.name.localeCompare(b.name); + case 'created': + return b.created - a.created || a.name.localeCompare(b.name); + default: + return a.name.localeCompare(b.name); + } +} + +// the name and, when the order needs them, the dates of a file or folder +export function readSortEntry(fullPath: string, isFolder: boolean, order: SortOrder): SortEntry { + const entry: SortEntry = { name: path.basename(fullPath), isFolder, modified: 0, created: 0 }; + if (needsDates(order)) { + try { + const stat = fs.statSync(fullPath); + entry.modified = stat.mtimeMs; + // some file systems don't record when a file was created, the date modified is the next best thing + entry.created = stat.birthtimeMs || stat.mtimeMs; + } catch (err) { + // gone since it was listed, it sorts last + } + } + return entry; +} diff --git a/src/test/suite/sortOrder.test.ts b/src/test/suite/sortOrder.test.ts new file mode 100644 index 0000000..7c5bc8c --- /dev/null +++ b/src/test/suite/sortOrder.test.ts @@ -0,0 +1,40 @@ +import * as assert from 'assert'; +import { compareEntries, SortEntry, SortOrder, toSortOrder } from '../../sortOrder'; + +suite('Sort Order (#22, #63)', () => { + const entries: SortEntry[] = [ + { name: 'beta.md', isFolder: false, modified: 300, created: 100 }, + { name: 'alpha.md', isFolder: false, modified: 100, created: 300 }, + { name: 'gamma.md', isFolder: false, modified: 200, created: 200 }, + { name: 'work', isFolder: true, modified: 50, created: 50 }, + { name: 'archive', isFolder: true, modified: 400, created: 10 }, + ]; + const sorted = (order: SortOrder) => [...entries].sort((a, b) => compareEntries(a, b, order)).map(entry => entry.name); + + test('by name, folders first', () => { + assert.deepStrictEqual(sorted('name'), ['archive', 'work', 'alpha.md', 'beta.md', 'gamma.md']); + }); + + test('by name from Z to A, folders still first', () => { + assert.deepStrictEqual(sorted('nameDescending'), ['work', 'archive', 'gamma.md', 'beta.md', 'alpha.md']); + }); + + test('by date modified, newest first', () => { + assert.deepStrictEqual(sorted('modified'), ['archive', 'work', 'beta.md', 'gamma.md', 'alpha.md']); + }); + + test('by date created, newest first', () => { + assert.deepStrictEqual(sorted('created'), ['work', 'archive', 'alpha.md', 'gamma.md', 'beta.md']); + }); + + test('the same date falls back to the name', () => { + const same: SortEntry[] = [{ name: 'b.md', isFolder: false, modified: 1, created: 1 }, { name: 'a.md', isFolder: false, modified: 1, created: 1 }]; + assert.deepStrictEqual(same.sort((a, b) => compareEntries(a, b, 'modified')).map(entry => entry.name), ['a.md', 'b.md']); + }); + + test('an unknown setting sorts by name', () => { + assert.strictEqual(toSortOrder('size'), 'name'); + assert.strictEqual(toSortOrder(undefined), 'name'); + assert.strictEqual(toSortOrder('modified'), 'modified'); + }); +});