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/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'; 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')); + }); });