Skip to content

Commit 8db48b9

Browse files
authored
Drag and drop, Run Folder (#24)
* Add drag and drop * feat: Enable drag and drop reordering by updating `sortOrder` for commands and folders and applying it to sort tree items. * feat: Introduce ability to run all commands within a folder in a new or active terminal, supporting a custom command joiner. * refactor: update drag and drop logic with optional chaining, refine warning message, and switch to for-of loop for command parameter resolution. * Generate some tests * supress lint warning * chore: Bump extension version to 1.2.0. * feat: document new 'Run Folder' and 'Drag and Drop' features, unit testing, and performance improvements in the changelog and readme.
1 parent 2b2790c commit 8db48b9

21 files changed

Lines changed: 608 additions & 40 deletions

.mocharc.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
{
22
"extensions": ["ts"],
33
"spec": ["src/test/**/*_spec.ts"],
4-
"node-option": [
5-
"loader=ts-node/esm"
6-
]
4+
"require": ["ts-node/register", "src/test/setup.js"]
75
}

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to the "save-commands" extension will be documented in this file.
44

5+
## [1.2.0]
6+
- **Run Folder**: Added ability to run all commands in a folder with a configurable joiner.
7+
- **Drag and Drop**: Added support for reordering and moving items between folders.
8+
- **Unit Testing**: Established a comprehensive unit test suite with VS Code API mocking.
9+
- **Performance**: Improved placeholder resolution logic.
10+
511
## [1.0.0]
612
- Create Folders
713
- Import/Export

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ It will ask input for `placeholder1` and `placeholder2`
1515
You can set `save-commands.placeholderType` to change the capturing group in settings.
1616

1717

18+
# Features
19+
20+
### Run Folder
21+
You can execute all commands within a folder as a single concatenated command.
22+
- **Configuration**: When editing a folder, you can set the "Join With" string (e.g., ` && `, ` ; `, ` | `) which determines how the individual commands are combined.
23+
- **Execution**: Click the play icon on a folder.
24+
25+
### Drag and Drop
26+
Organize your command library with ease:
27+
- **Reorder**: Drag commands or folders to change their sort order.
28+
- **Move**: Drag items into folders to nest them.
29+
- *Note*: Currently, moving items between "Global" and "Workspace" scopes is disabled to prevent accidental state corruption.
30+
1831
# Import/Export
1932

2033
You can import and export your commands.
@@ -23,6 +36,14 @@ You can import and export your commands.
2336

2437
Note: If you only want to replace only one of workspace commands or global commands, you can edit the json to remove the `global` or `workspace` property and then use it
2538

39+
# Development
40+
41+
If you are contributing to this project, you can run the unit test suite using:
42+
```bash
43+
npm run unit-test
44+
```
45+
The tests use a custom VS Code mock to run in a standalone environment.
46+
2647
---
2748

2849
**Enjoy!**

package.json

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "Save Commands",
44
"description": "Simple VSCode Extension to save and execute terminal commands.",
55
"icon": "media/save-commands.png",
6-
"version": "1.0.3",
6+
"version": "1.2.0",
77
"engines": {
88
"vscode": "^1.74.0"
99
},
@@ -157,6 +157,24 @@
157157
"light": "resources/light/import.svg",
158158
"dark": "resources/dark/import.svg"
159159
}
160+
},
161+
{
162+
"command": "save-commands.runFolder",
163+
"title": "Try Running Folder Commands",
164+
"category": "Save Commands",
165+
"icon": {
166+
"light": "resources/light/run.svg",
167+
"dark": "resources/dark/run.svg"
168+
}
169+
},
170+
{
171+
"command": "save-commands.runFolderInActiveTerminal",
172+
"title": "Try Running Folder Commands In Active Terminal",
173+
"category": "Save Commands",
174+
"icon": {
175+
"light": "resources/light/run_active.svg",
176+
"dark": "resources/dark/run_active.svg"
177+
}
160178
}
161179
],
162180
"views": {
@@ -261,6 +279,16 @@
261279
"command": "save-commands.editFolder",
262280
"when": "view == save-commands-view && viewItem == folder"
263281
},
282+
{
283+
"command": "save-commands.runFolder",
284+
"group": "inline",
285+
"when": "view == save-commands-view && viewItem == folder"
286+
},
287+
{
288+
"command": "save-commands.runFolderInActiveTerminal",
289+
"group": "inline",
290+
"when": "view == save-commands-view && viewItem == folder"
291+
},
264292
{
265293
"command": "save-commands.copyCommand",
266294
"when": "view == save-commands-view && viewItem == command",

src/DragAndDropController.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import * as vscode from "vscode";
2+
import type TreeItem from "./TreeItem";
3+
import { ContextValue } from "./TreeProvider";
4+
import Command from "./models/command";
5+
import { CommandFolder } from "./models/command_folder";
6+
import { ExecCommands } from "./models/exec_commands";
7+
import { StateType } from "./models/etters";
8+
9+
export default class DragAndDropController implements vscode.TreeDragAndDropController<TreeItem> {
10+
dragMimeTypes = ["application/vnd.code.tree.save-commands-view"];
11+
dropMimeTypes = ["application/vnd.code.tree.save-commands-view"];
12+
13+
constructor(private context: vscode.ExtensionContext) { }
14+
15+
public async handleDrag(
16+
source: readonly TreeItem[],
17+
dataTransfer: vscode.DataTransfer,
18+
token: vscode.CancellationToken,
19+
): Promise<void> {
20+
if (source.length > 0) {
21+
dataTransfer.set(
22+
"application/vnd.code.tree.save-commands-view",
23+
new vscode.DataTransferItem(source[0]),
24+
);
25+
}
26+
}
27+
28+
public async handleDrop(
29+
target: TreeItem | undefined,
30+
dataTransfer: vscode.DataTransfer,
31+
token: vscode.CancellationToken,
32+
): Promise<void> {
33+
const transferItem = dataTransfer.get("application/vnd.code.tree.save-commands-view");
34+
if (!transferItem) {
35+
return;
36+
}
37+
38+
const sourceItem: TreeItem = transferItem.value;
39+
if (!sourceItem || !sourceItem.id) {
40+
return;
41+
}
42+
43+
// Prevent moving between Global and Workspace for now to keep it simple and safe
44+
const targetStateType = target?.stateType ?? sourceItem.stateType;
45+
if (sourceItem.stateType !== targetStateType) {
46+
vscode.window.showWarningMessage("Moving items between Global and Workspace is not supported yet.");
47+
return;
48+
}
49+
50+
// Determine new parentFolderId
51+
let newParentFolderId: string | null = null;
52+
if (target) {
53+
if (target.contextValue === ContextValue.folder) {
54+
newParentFolderId = target.id ?? null;
55+
} else if (target.contextValue === ContextValue.command) {
56+
newParentFolderId = target.parentFolderId ?? null;
57+
}
58+
}
59+
60+
// Prevent moving a folder into itself
61+
if (sourceItem.contextValue === ContextValue.folder && sourceItem.id === newParentFolderId) {
62+
return;
63+
}
64+
65+
try {
66+
if (sourceItem.contextValue === ContextValue.command) {
67+
await this.moveCommand(sourceItem, newParentFolderId, target);
68+
} else if (sourceItem.contextValue === ContextValue.folder) {
69+
await this.moveFolder(sourceItem, newParentFolderId, target);
70+
}
71+
72+
vscode.commands.executeCommand(ExecCommands.refreshView);
73+
} catch (error) {
74+
vscode.window.showErrorMessage(`Error moving item: ${error}`);
75+
}
76+
}
77+
78+
private async moveCommand(
79+
sourceItem: TreeItem,
80+
newParentFolderId: string | null,
81+
target: TreeItem | undefined,
82+
) {
83+
const { etter } = Command.getEtterFromTreeContext(sourceItem);
84+
const commands = etter.getValue(this.context);
85+
const sourceIndex = commands.findIndex((c) => c.id === sourceItem.id);
86+
87+
if (sourceIndex === -1) return;
88+
89+
const [command] = commands.splice(sourceIndex, 1);
90+
command.parentFolderId = newParentFolderId;
91+
92+
// If dropped on another command or folder, we can try to reorder
93+
if (target?.id && target.contextValue !== ContextValue.root) {
94+
const targetIndex = commands.findIndex((c) => c.id === target.id);
95+
if (targetIndex !== -1) {
96+
commands.splice(targetIndex, 0, command);
97+
} else {
98+
commands.push(command);
99+
}
100+
} else {
101+
commands.push(command);
102+
}
103+
104+
// Update sortOrder for all commands to match array index
105+
for (let i = 0; i < commands.length; i++) {
106+
commands[i].sortOrder = i;
107+
}
108+
109+
await etter.setValue(this.context, commands);
110+
}
111+
112+
private async moveFolder(
113+
sourceItem: TreeItem,
114+
newParentFolderId: string | null,
115+
target: TreeItem | undefined,
116+
) {
117+
const { etter } = CommandFolder.getEtterFromTreeContext(sourceItem);
118+
const folders = etter.getValue(this.context);
119+
const sourceIndex = folders.findIndex((f) => f.id === sourceItem.id);
120+
121+
if (sourceIndex === -1) return;
122+
123+
const [folder] = folders.splice(sourceIndex, 1);
124+
125+
// Prevent nesting if it would cause a cycle (simple check: don't move into a descendant)
126+
// For now, let's just allow top level or direct parent update
127+
folder.parentFolderId = newParentFolderId;
128+
129+
if (target?.id && target.contextValue !== ContextValue.root) {
130+
const targetIndex = folders.findIndex((f) => f.id === target.id);
131+
if (targetIndex !== -1) {
132+
folders.splice(targetIndex, 0, folder);
133+
} else {
134+
folders.push(folder);
135+
}
136+
} else {
137+
folders.push(folder);
138+
}
139+
140+
// Update sortOrder for all folders to match array index
141+
for (let i = 0; i < folders.length; i++) {
142+
folders[i].sortOrder = i;
143+
}
144+
145+
await etter.setValue(this.context, folders);
146+
}
147+
}

src/TreeProvider.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,15 @@ class TreeDataProvider implements vscode.TreeDataProvider<TreeItem> {
8989
return false;
9090
});
9191

92-
// TODO: Sort based on sort order
92+
// Sort based on sort order
93+
const sortFn = (a: TreeItem, b: TreeItem) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
94+
filteredItems.sort(sortFn);
95+
96+
for (const item of items) {
97+
if (item.children) {
98+
item.children.sort(sortFn);
99+
}
100+
}
93101

94102
return filteredItems;
95103
}
@@ -122,25 +130,25 @@ class TreeDataProvider implements vscode.TreeDataProvider<TreeItem> {
122130
globalTree.length !== 0
123131
? globalTree
124132
: [
125-
new TreeItem({
126-
id: null,
127-
label: "No Commands Found",
128-
contextValue: ContextValue.none,
129-
stateType: StateType.global,
130-
}),
131-
];
133+
new TreeItem({
134+
id: null,
135+
label: "No Commands Found",
136+
contextValue: ContextValue.none,
137+
stateType: StateType.global,
138+
}),
139+
];
132140

133141
const workspaceTreeItems: Array<TreeItem> =
134142
workspaceTree.length !== 0
135143
? workspaceTree
136144
: [
137-
new TreeItem({
138-
id: null,
139-
label: "No Commands Found",
140-
contextValue: ContextValue.none,
141-
stateType: StateType.workspace,
142-
}),
143-
];
145+
new TreeItem({
146+
id: null,
147+
label: "No Commands Found",
148+
contextValue: ContextValue.none,
149+
stateType: StateType.workspace,
150+
}),
151+
];
144152

145153
this.data = [
146154
new TreeItem({

src/extension.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@ import {
1616
editFolderFn,
1717
exportFn,
1818
importFn,
19+
runFolderFn,
20+
runFolderInActiveTerminalFn,
1921
} from "./functions";
22+
import DragAndDropController from "./DragAndDropController";
2023

2124
export function activate(context: vscode.ExtensionContext) {
2225
const treeView = new TreeDataProvider(context);
26+
const treeDnDController = new DragAndDropController(context);
2327

2428
// biome-ignore lint/suspicious/noExplicitAny: Needed
2529
const callbacks: Record<ExecCommands, (...args: any[]) => any> = {
@@ -38,15 +42,20 @@ export function activate(context: vscode.ExtensionContext) {
3842
[ExecCommands.editFolder]: editFolderFn(context),
3943
[ExecCommands.export]: exportFn(context),
4044
[ExecCommands.import]: importFn(context),
45+
[ExecCommands.runFolder]: runFolderFn(context),
46+
[ExecCommands.runFolderInActiveTerminal]: runFolderInActiveTerminalFn(context),
4147
};
4248

4349
const subscriptions = Object.keys(callbacks).map((key) => {
4450
return vscode.commands.registerCommand(key, callbacks[key as ExecCommands]);
4551
});
4652

47-
vscode.window.registerTreeDataProvider("save-commands-view", treeView);
53+
vscode.window.createTreeView("save-commands-view", {
54+
treeDataProvider: treeView,
55+
dragAndDropController: treeDnDController,
56+
});
4857
context.subscriptions.push(...subscriptions);
4958
}
5059

5160
// this method is called when your extension is deactivated
52-
export function deactivate() {}
61+
export function deactivate() { }

src/functions/editFolder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ export default function (context: vscode.ExtensionContext) {
1616
if (i > -1) {
1717
const val = await commandFolderInput({
1818
name: folders[i].name,
19+
joinWith: folders[i].joinWith,
1920
});
2021
folders[i].name = val.name;
22+
folders[i].joinWith = val.joinWith;
2123
etter.setValue(context, folders);
2224
vscode.commands.executeCommand(ExecCommands.refreshView);
2325
} else {

src/functions/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import deleteFolderFn from "./deleteFolder";
1111
import editFolderFn from "./editFolder";
1212
import exportFn from "./export";
1313
import importFn from "./import";
14+
import runFolderFn from "./runFolder";
15+
import runFolderInActiveTerminalFn from "./runFolderInActiveTerminal";
1416

1517
export {
1618
deleteCommandFn,
@@ -26,4 +28,6 @@ export {
2628
editFolderFn,
2729
exportFn,
2830
importFn,
31+
runFolderFn,
32+
runFolderInActiveTerminalFn,
2933
};

0 commit comments

Comments
 (0)