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: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -374,6 +389,10 @@
{
"command": "Notes.createWorkspaceNotesFolder",
"when": "false"
},
{
"command": "Notes.move",
"when": "false"
}
]
},
Expand Down
117 changes: 114 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> {
// 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<string | undefined> {
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
Expand Down Expand Up @@ -802,3 +897,19 @@ export class Notes {
}
}
}

// every folder below a folder, depth first and sorted by name
async function listFolders(folder: string): Promise<string[]> {
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;
}
15 changes: 15 additions & 0 deletions src/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
11 changes: 3 additions & 8 deletions src/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
57 changes: 57 additions & 0 deletions src/notesDragAndDrop.ts
Original file line number Diff line number Diff line change
@@ -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<Note> {

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<void>) {
}

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<void> {
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<MovableItem[] | undefined> {
const values: Thenable<string>[] = [];
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;
}
31 changes: 30 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, 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');
Expand Down Expand Up @@ -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'));
});
});
Loading