From 970fc9f9a64033962b326e71e3b262b0075b84a3 Mon Sep 17 00:00:00 2001 From: Dion Munk Date: Tue, 6 Oct 2026 23:30:34 -0600 Subject: [PATCH 1/2] feat(notes): use the file icon theme for notes and folders Every note showed the same generic file icon. Tree items now carry their resourceUri with the File and Folder theme icons, so VS Code takes the icon from the current file icon theme: by extension for notes and by name for folders. Based on the same change in the community fork from #82. --- src/note.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/note.ts b/src/note.ts index daaad4b..6ce7599 100644 --- a/src/note.ts +++ b/src/note.ts @@ -21,14 +21,9 @@ export class Note extends vscode.TreeItem { this.isFolder = isDirectory; this.fullPath = path.join(location, name); - // Set appropriate icon based on whether this is a folder or file - if (isDirectory) { - // Use VS Code's built-in folder icons - this.iconPath = new vscode.ThemeIcon('folder'); - } else { - // Use VS Code's built-in file type icons based on extension - this.iconPath = vscode.ThemeIcon.File; - } + // the file icon theme picks the icon from the resource, by extension for a note and by name for a folder + this.resourceUri = vscode.Uri.file(this.fullPath); + this.iconPath = isDirectory ? vscode.ThemeIcon.Folder : vscode.ThemeIcon.File; // Set contextValue based on whether this is a folder or note this.contextValue = isDirectory ? 'folder' : 'note'; From 90420342d553a27b7b7fba5d464868a8da8eba91 Mon Sep 17 00:00:00 2001 From: Dion Munk Date: Tue, 6 Oct 2026 23:30:34 -0600 Subject: [PATCH 2/2] feat(notes): move notes and folders with drag and drop or Move To Notes and folders could only be moved outside VS Code (#41). - drag notes and folders onto a folder, onto empty space for the top level, or between Workspace Notes and Notes; several selected items move together - Move To... in the right-click menu lists every folder of both sections - moves go through a WorkspaceEdit, so open editors follow the moved note - a folder can't be moved into itself, an existing item is never overwritten, and a note inside a folder that moves too goes along with it - drag a note onto the editor area to open it Drag data is carried as JSON: VS Code only keeps the original objects for drops within the same view, and a drop on the other view delivers the type twice, the first time empty. Move support was suggested by the community fork in #82. --- CHANGELOG.md | 3 + README.md | 1 + package.json | 19 ++++++ src/extension.ts | 117 +++++++++++++++++++++++++++++++- src/location.ts | 15 ++++ src/notesDragAndDrop.ts | 57 ++++++++++++++++ src/test/suite/location.test.ts | 31 ++++++++- 7 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 src/notesDragAndDrop.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf63b7..af09961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * a Workspace Notes section above Notes for the notes of the open workspace, shown when the workspace has notes (#4), with the new `notes.workspaceNotesLocation` setting and the `Notes: Set Up Workspace Notes` and `Notes: Select Workspace Notes Location` commands * `Notes: New Note` and `Notes: New Folder` ask whether to use Workspace Notes or Notes when both are available * 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 and folders use the icons of your file icon theme ### Changed diff --git a/README.md b/README.md index 79a848b..5fdf868 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ 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 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. * 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. ## Getting Started diff --git a/package.json b/package.json index 6f54407..fc2daf3 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,11 @@ "dark": "resources/dark/rename.svg" } }, + { + "command": "Notes.move", + "title": "Move To...", + "category": "Notes" + }, { "command": "Notes.revealInOS", "title": "Reveal in File Explorer", @@ -283,6 +288,16 @@ "when": "view =~ /^(notes|workspaceNotes)$/ && viewItem == folder", "group": "1_modification@2" }, + { + "command": "Notes.move", + "when": "view =~ /^(notes|workspaceNotes)$/ && viewItem == note", + "group": "1_modification@3" + }, + { + "command": "Notes.move", + "when": "view =~ /^(notes|workspaceNotes)$/ && viewItem == folder", + "group": "1_modification@3" + }, { "command": "Notes.newNote", "when": "view =~ /^(notes|workspaceNotes)$/ && viewItem == folder", @@ -374,6 +389,10 @@ { "command": "Notes.createWorkspaceNotesFolder", "when": "false" + }, + { + "command": "Notes.move", + "when": "false" } ] }, diff --git a/src/extension.ts b/src/extension.ts index 8016028..db4d931 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,7 +6,8 @@ import * as path from 'path'; import { Note } from './note'; import { NotesViewProvider } from './notesViewProvider'; import { NotesWatcher } from './notesWatcher'; -import { getLocationState, getPortableFolder, resolveNotesLocation, toWorkspaceSetting } from './location'; +import { MovableItem, NotesDragAndDrop } from './notesDragAndDrop'; +import { getLocationState, getMoveDestination, getPortableFolder, isInside, resolveNotesLocation, toWorkspaceSetting } from './location'; let extId = 'vscode-notes'; let extPub = 'dionmunk'; @@ -24,12 +25,23 @@ export function activate(context: vscode.ExtensionContext) { console.log('"vscode-notes" is active.'); + // dragging and dropping moves notes, refreshing both trees (only called once activation is done) + const moveItems = (items: readonly MovableItem[], targetFolder: string) => Notes.moveItems(items, targetFolder, trees); + // Notes: the location in your user settings, the same in every window let notesTree = new NotesViewProvider('', String(Notes.getNotesExtensions())); - let notesView = vscode.window.createTreeView('notes', { treeDataProvider: notesTree.init() }); + let notesView = vscode.window.createTreeView('notes', { + treeDataProvider: notesTree.init(), + dragAndDropController: new NotesDragAndDrop(() => notesTree.location, moveItems), + canSelectMany: true + }); // Workspace Notes: the notes of the open workspace, shown above Notes when it has some let workspaceTree = new NotesViewProvider('', String(Notes.getNotesExtensions()), 'notes.workspaceLocationState'); - let workspaceView = vscode.window.createTreeView('workspaceNotes', { treeDataProvider: workspaceTree.init() }); + let workspaceView = vscode.window.createTreeView('workspaceNotes', { + treeDataProvider: workspaceTree.init(), + dragAndDropController: new NotesDragAndDrop(() => workspaceTree.location, moveItems), + canSelectMany: true + }); // keep both trees in sync with changes made outside of this window let notesWatcher = new NotesWatcher(notesTree, notesView); @@ -156,6 +168,16 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push(renameNoteDisposable); + // move notes and folders, the selected ones when the clicked item is part of the selection + let moveDisposable = vscode.commands.registerCommand('Notes.move', async (item?: Note, items?: Note[]) => { + const selected = (items?.length ? items : item ? [item] : []).filter(selectedItem => selectedItem instanceof Note); + const targetFolder = selected.length ? await Notes.pickMoveTarget(selected) : undefined; + if (targetFolder) { + await Notes.moveItems(selected, targetFolder, trees); + } + }); + context.subscriptions.push(moveDisposable); + // rename folder let renameFolderDisposable = vscode.commands.registerCommand('Notes.renameFolder', (folder: Note) => { Notes.renameFolder(folder, trees); @@ -685,6 +707,79 @@ export class Notes { }); } + // move notes and folders into a folder, through VS Code so open editors follow them + static async moveItems(items: readonly MovableItem[], targetFolder: string, tree: Refreshable): Promise { + // a note inside a folder that moves too goes along with it + const moving = items.filter(item => !items.some(other => other !== item && other.isFolder && isInside(item.fullPath, other.fullPath))); + const problems: string[] = []; + let moved = 0; + + for (const item of moving) { + const destination = getMoveDestination(item.fullPath, targetFolder); + if (!destination) { + if (item.isFolder && isInside(targetFolder, item.fullPath)) { + problems.push(`'${item.name}' can't be moved into itself.`); + } + continue; + } + if (fs.existsSync(destination)) { + problems.push(`'${item.name}' already exists in '${path.basename(targetFolder)}'.`); + continue; + } + const edit = new vscode.WorkspaceEdit(); + edit.renameFile(vscode.Uri.file(item.fullPath), vscode.Uri.file(destination), { overwrite: false }); + if (await vscode.workspace.applyEdit(edit)) { + moved++; + } + else { + problems.push(`Failed to move '${item.name}'.`); + } + } + + if (moved) { + tree.refresh(); + } + if (problems.length) { + vscode.window.showWarningMessage(problems.join(' ')); + } + } + + // pick a folder to move notes and folders into, from Workspace Notes and Notes + static async pickMoveTarget(items: readonly MovableItem[]): Promise { + const sections = [{ label: 'Notes', root: Notes.getNotesLocation() }]; + if (Notes.isWorkspaceNotesAvailable()) { + sections.unshift({ label: 'Workspace Notes', root: Notes.getWorkspaceNotesLocation() }); + } + + const picks: (vscode.QuickPickItem & { folder?: string })[] = []; + for (const section of sections) { + if (getLocationState(section.root) !== 'ready') { + continue; + } + // only folders where at least one of the items can go + const folders = [section.root, ...await listFolders(section.root)] + .filter(folder => items.some(item => getMoveDestination(item.fullPath, folder))); + if (!folders.length) { + continue; + } + picks.push({ label: section.label, kind: vscode.QuickPickItemKind.Separator }); + for (const folder of folders) { + const relative = path.relative(section.root, folder); + picks.push(relative + ? { label: `$(folder) ${relative.split(path.sep).join('/')}`, folder } + : { label: `$(root-folder) ${section.label}`, description: 'top level', folder }); + } + } + + if (!picks.length) { + vscode.window.showInformationMessage('There is no other folder to move to.'); + return undefined; + } + const what = items.length === 1 ? `'${items[0].name}'` : `${items.length} items`; + const picked = await vscode.window.showQuickPick(picks, { placeHolder: `Move ${what} to` }); + return picked?.folder; + } + // setup notes static setupNotes(tree?: NotesViewProvider): void { // Check if notesLocation is not null @@ -802,3 +897,19 @@ export class Notes { } } } + +// every folder below a folder, depth first and sorted by name +async function listFolders(folder: string): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(folder, { withFileTypes: true }); + } catch (err) { + return []; + } + const folders: string[] = []; + for (const entry of entries.filter(entry => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { + const child = path.join(folder, entry.name); + folders.push(child, ...await listFolders(child)); + } + return folders; +} diff --git a/src/location.ts b/src/location.ts index 5f63fe3..85b3bb0 100644 --- a/src/location.ts +++ b/src/location.ts @@ -69,3 +69,18 @@ export function getPortableFolder(env: NodeJS.ProcessEnv = process.env): string const portableData = env.VSCODE_PORTABLE; return portableData ? path.dirname(portableData) : undefined; } + +// is a path the folder itself or somewhere inside it? +export function isInside(target: string, folder: string): boolean { + const relative = path.relative(folder, target); + return relative === '' || !(relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)); +} + +// where a note or folder ends up when moved into a folder, or undefined when the move would do nothing +// (it is already there) or is impossible (a folder moved into itself) +export function getMoveDestination(source: string, targetFolder: string): string | undefined { + if (isInside(targetFolder, source) || path.resolve(path.dirname(source)) === path.resolve(targetFolder)) { + return undefined; + } + return path.join(targetFolder, path.basename(source)); +} diff --git a/src/notesDragAndDrop.ts b/src/notesDragAndDrop.ts new file mode 100644 index 0000000..16bcb87 --- /dev/null +++ b/src/notesDragAndDrop.ts @@ -0,0 +1,57 @@ +import * as vscode from 'vscode'; +import { Note } from './note'; + +// the same type in both views, so notes can be dragged between Workspace Notes and Notes +const NOTES_MIME_TYPE = 'application/vnd.notes.items'; + +// what a move needs to know about a note or folder; a drag carries it as text, since only drops +// within the same view keep the original objects +export interface MovableItem { + name: string; + fullPath: string; + isFolder: boolean; +} + +// drag notes and folders onto a folder, or onto empty space for the top level, to move them +export class NotesDragAndDrop implements vscode.TreeDragAndDropController { + + readonly dragMimeTypes = [NOTES_MIME_TYPE, 'text/uri-list']; + readonly dropMimeTypes = [NOTES_MIME_TYPE]; + + constructor( + // the top level folder of this view, '' when it has none + private root: () => string, + private move: (items: readonly MovableItem[], targetFolder: string) => Thenable) { + } + + handleDrag(source: readonly Note[], dataTransfer: vscode.DataTransfer): void { + const items: MovableItem[] = source.map(({ name, fullPath, isFolder }) => ({ name, fullPath, isFolder })); + dataTransfer.set(NOTES_MIME_TYPE, new vscode.DataTransferItem(JSON.stringify(items))); + // dropped on the editor area, the notes open + const uris = source.filter(item => !item.isFolder).map(item => vscode.Uri.file(item.fullPath).toString()); + if (uris.length) { + dataTransfer.set('text/uri-list', new vscode.DataTransferItem(uris.join('\r\n'))); + } + } + + async handleDrop(target: Note | undefined, dataTransfer: vscode.DataTransfer): Promise { + const items = await readItems(dataTransfer); + // dropped on a note, it goes next to the note + const targetFolder = target ? (target.isFolder ? target.fullPath : target.location) : this.root(); + if (items?.length && targetFolder) { + await this.move(items, targetFolder); + } + } +} + +// the dragged items; dropped on the other view the type arrives twice, once empty, so use the entry that has them +async function readItems(dataTransfer: vscode.DataTransfer): Promise { + const values: Thenable[] = []; + dataTransfer.forEach((item, mimeType) => { + if (mimeType === NOTES_MIME_TYPE) { + values.push(item.asString()); + } + }); + const data = (await Promise.all(values)).find(value => value.length > 0); + return data ? JSON.parse(data) : undefined; +} diff --git a/src/test/suite/location.test.ts b/src/test/suite/location.test.ts index 0f4d727..3fcf999 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, getPortableFolder, resolveNotesLocation, toWorkspaceSetting } from '../../location'; +import { getLocationState, getMoveDestination, getPortableFolder, isInside, resolveNotesLocation, toWorkspaceSetting } from '../../location'; suite('Notes Location', () => { const home = path.join(path.sep, 'home', 'someone'); @@ -90,4 +90,33 @@ suite('Notes Location', () => { const portable = getPortableFolder({ VSCODE_PORTABLE: path.join(install, 'data') }); assert.strictEqual(resolveNotesLocation('data/Notes', home, portable), path.join(install, 'data', 'Notes')); }); + + test('a path is inside a folder when it is the folder or below it', () => { + const notes = path.join(path.sep, 'notes'); + assert.strictEqual(isInside(notes, notes), true); + assert.strictEqual(isInside(path.join(notes, 'work', 'a.md'), notes), true); + assert.strictEqual(isInside(path.join(path.sep, 'notes-archive'), notes), false); + assert.strictEqual(isInside(path.join(notes, '..notes'), notes), true); + assert.strictEqual(isInside(path.sep, notes), false); + }); + + test('moving a note into a folder puts it there', () => { + const notes = path.join(path.sep, 'notes'); + assert.strictEqual(getMoveDestination(path.join(notes, 'a.md'), path.join(notes, 'work')), path.join(notes, 'work', 'a.md')); + assert.strictEqual(getMoveDestination(path.join(notes, 'work', 'a.md'), notes), path.join(notes, 'a.md')); + }); + + test('moving into the folder an item is already in does nothing', () => { + const notes = path.join(path.sep, 'notes'); + assert.strictEqual(getMoveDestination(path.join(notes, 'a.md'), notes), undefined); + assert.strictEqual(getMoveDestination(path.join(notes, 'work'), notes + path.sep), undefined); + }); + + test('a folder can not be moved into itself or a folder inside it', () => { + const work = path.join(path.sep, 'notes', 'work'); + assert.strictEqual(getMoveDestination(work, work), undefined); + assert.strictEqual(getMoveDestination(work, path.join(work, 'old')), undefined); + // 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')); + }); });