@jk3labs/paperclip-plugin-file-browser 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/file-browser.spec.js +156 -0
- package/dist/index.js +113 -0
- package/dist/ui/FileBrowser.js +46 -0
- package/package.json +6 -2
- package/src/plugin.json +5 -0
- package/IMPLEMENTATION_NOTES.md +0 -0
- package/index.js +0 -82
- package/jest.config.js +0 -6
- package/manifest.ts +0 -16
- package/src/__tests__/file-browser.spec.ts +0 -132
- package/src/index.ts +0 -131
- package/src/ui/FileBrowser.tsx +0 -82
- package/tsconfig.json +0 -16
- /package/{plugin.json → dist/plugin.json} +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const express_1 = __importStar(require("express"));
|
|
40
|
+
const supertest_1 = __importDefault(require("supertest"));
|
|
41
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
42
|
+
const path_1 = __importDefault(require("path"));
|
|
43
|
+
// Mock the plugin router directly
|
|
44
|
+
const mockRouter = (0, express_1.Router)();
|
|
45
|
+
mockRouter.get('/list', (req, res) => {
|
|
46
|
+
const relativePath = req.query.path || '';
|
|
47
|
+
const mockRootDir = path_1.default.join(__dirname, 'mock-root');
|
|
48
|
+
const targetPath = path_1.default.join(mockRootDir, relativePath);
|
|
49
|
+
fs_extra_1.default.pathExists(targetPath)
|
|
50
|
+
.then(exists => {
|
|
51
|
+
if (!exists)
|
|
52
|
+
return res.status(404).json({ error: 'Directory not found' });
|
|
53
|
+
fs_extra_1.default.readdir(targetPath, { withFileTypes: true })
|
|
54
|
+
.then(entries => {
|
|
55
|
+
const files = entries.filter(dirent => dirent.isFile()).map(dirent => dirent.name);
|
|
56
|
+
const directories = entries.filter(dirent => dirent.isDirectory()).map(dirent => dirent.name);
|
|
57
|
+
res.json({ files, directories, currentPath: relativePath });
|
|
58
|
+
})
|
|
59
|
+
.catch(() => res.status(500).json({ error: 'Failed to read directory' }));
|
|
60
|
+
})
|
|
61
|
+
.catch(() => res.status(500).json({ error: 'Server error' }));
|
|
62
|
+
});
|
|
63
|
+
mockRouter.get('/download', (req, res) => {
|
|
64
|
+
const relativePath = req.query.path;
|
|
65
|
+
if (!relativePath)
|
|
66
|
+
return res.status(400).json({ error: 'Path parameter is required' });
|
|
67
|
+
const mockRootDir = path_1.default.join(__dirname, 'mock-root');
|
|
68
|
+
const targetPath = path_1.default.join(mockRootDir, relativePath);
|
|
69
|
+
fs_extra_1.default.pathExists(targetPath)
|
|
70
|
+
.then(exists => {
|
|
71
|
+
if (!exists)
|
|
72
|
+
return res.status(404).json({ error: 'File not found' });
|
|
73
|
+
fs_extra_1.default.stat(targetPath)
|
|
74
|
+
.then(stats => {
|
|
75
|
+
if (!stats.isFile())
|
|
76
|
+
return res.status(404).json({ error: 'File not found' });
|
|
77
|
+
res.sendFile(targetPath);
|
|
78
|
+
})
|
|
79
|
+
.catch(() => res.status(500).json({ error: 'Failed to read file' }));
|
|
80
|
+
})
|
|
81
|
+
.catch(() => res.status(500).json({ error: 'Server error' }));
|
|
82
|
+
});
|
|
83
|
+
mockRouter.get('/zip', (req, res) => {
|
|
84
|
+
const relativePath = req.query.path;
|
|
85
|
+
if (!relativePath)
|
|
86
|
+
return res.status(400).json({ error: 'Path parameter is required' });
|
|
87
|
+
const mockRootDir = path_1.default.join(__dirname, 'mock-root');
|
|
88
|
+
const targetPath = path_1.default.join(mockRootDir, relativePath);
|
|
89
|
+
fs_extra_1.default.pathExists(targetPath)
|
|
90
|
+
.then(exists => {
|
|
91
|
+
if (!exists)
|
|
92
|
+
return res.status(404).json({ error: 'Directory not found' });
|
|
93
|
+
fs_extra_1.default.stat(targetPath)
|
|
94
|
+
.then(stats => {
|
|
95
|
+
if (!stats.isDirectory())
|
|
96
|
+
return res.status(404).json({ error: 'Directory not found' });
|
|
97
|
+
res.status(500).json({ error: 'ZIP functionality not implemented in mock' });
|
|
98
|
+
})
|
|
99
|
+
.catch(() => res.status(500).json({ error: 'Failed to read directory' }));
|
|
100
|
+
})
|
|
101
|
+
.catch(() => res.status(500).json({ error: 'Server error' }));
|
|
102
|
+
});
|
|
103
|
+
describe('File Browser Plugin', () => {
|
|
104
|
+
let app;
|
|
105
|
+
const mockRootDir = path_1.default.join(__dirname, 'mock-root');
|
|
106
|
+
const mockFilePath = path_1.default.join(mockRootDir, 'test.txt');
|
|
107
|
+
const mockDirPath = path_1.default.join(mockRootDir, 'test-dir');
|
|
108
|
+
beforeAll(async () => {
|
|
109
|
+
// Setup mock filesystem
|
|
110
|
+
await fs_extra_1.default.ensureDir(mockRootDir);
|
|
111
|
+
await fs_extra_1.default.writeFile(mockFilePath, 'test content');
|
|
112
|
+
await fs_extra_1.default.ensureDir(mockDirPath);
|
|
113
|
+
await fs_extra_1.default.writeFile(path_1.default.join(mockDirPath, 'nested.txt'), 'nested content');
|
|
114
|
+
// Setup Express app
|
|
115
|
+
app = (0, express_1.default)();
|
|
116
|
+
app.use('/files', mockRouter);
|
|
117
|
+
});
|
|
118
|
+
afterAll(async () => {
|
|
119
|
+
// Cleanup mock filesystem
|
|
120
|
+
await fs_extra_1.default.remove(mockRootDir);
|
|
121
|
+
});
|
|
122
|
+
describe('GET /files/list', () => {
|
|
123
|
+
it('should list files and directories in root', async () => {
|
|
124
|
+
const response = await (0, supertest_1.default)(app).get('/files/list');
|
|
125
|
+
expect(response.status).toBe(200);
|
|
126
|
+
expect(response.body.files).toContain('test.txt');
|
|
127
|
+
expect(response.body.directories).toContain('test-dir');
|
|
128
|
+
});
|
|
129
|
+
it('should list files and directories in subdirectory', async () => {
|
|
130
|
+
const response = await (0, supertest_1.default)(app).get('/files/list?path=test-dir');
|
|
131
|
+
expect(response.status).toBe(200);
|
|
132
|
+
expect(response.body.files).toContain('nested.txt');
|
|
133
|
+
});
|
|
134
|
+
it('should return 404 for non-existent directory', async () => {
|
|
135
|
+
const response = await (0, supertest_1.default)(app).get('/files/list?path=non-existent');
|
|
136
|
+
expect(response.status).toBe(404);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('GET /files/download', () => {
|
|
140
|
+
it('should download a file', async () => {
|
|
141
|
+
const response = await (0, supertest_1.default)(app).get('/files/download?path=test.txt');
|
|
142
|
+
expect(response.status).toBe(200);
|
|
143
|
+
expect(response.text).toBe('test content');
|
|
144
|
+
});
|
|
145
|
+
it('should return 404 for non-existent file', async () => {
|
|
146
|
+
const response = await (0, supertest_1.default)(app).get('/files/download?path=non-existent.txt');
|
|
147
|
+
expect(response.status).toBe(404);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('GET /files/zip', () => {
|
|
151
|
+
it('should return 404 for non-existent directory', async () => {
|
|
152
|
+
const response = await (0, supertest_1.default)(app).get('/files/zip?path=non-existent');
|
|
153
|
+
expect(response.status).toBe(404);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const express_1 = __importDefault(require("express"));
|
|
7
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const archiver_1 = __importDefault(require("archiver"));
|
|
10
|
+
;
|
|
11
|
+
const plugin = {
|
|
12
|
+
id: 'file-browser',
|
|
13
|
+
name: 'File Browser Plugin',
|
|
14
|
+
description: 'A plugin to browse, download, and zip files in the Paperclip default workspace.',
|
|
15
|
+
register: (context) => {
|
|
16
|
+
console.debug('[File Browser Plugin] Registering plugin');
|
|
17
|
+
const router = express_1.default.Router();
|
|
18
|
+
const rootDir = context.environment.rootDir;
|
|
19
|
+
// Validate rootDir exists
|
|
20
|
+
console.debug(`[File Browser Plugin] Root directory: ${rootDir}`);
|
|
21
|
+
if (!rootDir || !(fs_extra_1.default.existsSync(rootDir))) {
|
|
22
|
+
console.error(`[File Browser Plugin] Invalid root directory: ${rootDir}`);
|
|
23
|
+
throw new Error(`Invalid root directory: ${rootDir}`);
|
|
24
|
+
}
|
|
25
|
+
router.get('/list', async (req, res) => {
|
|
26
|
+
try {
|
|
27
|
+
const relativePath = req.query.path || '';
|
|
28
|
+
const targetPath = path_1.default.join(rootDir, relativePath);
|
|
29
|
+
// Security: Ensure targetPath is within rootDir
|
|
30
|
+
if (!targetPath.startsWith(rootDir)) {
|
|
31
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
32
|
+
}
|
|
33
|
+
const entries = await fs_extra_1.default.readdir(targetPath, { withFileTypes: true });
|
|
34
|
+
const files = entries
|
|
35
|
+
.filter(dirent => dirent.isFile())
|
|
36
|
+
.map(dirent => dirent.name);
|
|
37
|
+
const directories = entries
|
|
38
|
+
.filter(dirent => dirent.isDirectory())
|
|
39
|
+
.map(dirent => dirent.name);
|
|
40
|
+
res.json({
|
|
41
|
+
files,
|
|
42
|
+
directories,
|
|
43
|
+
currentPath: path_1.default.relative(rootDir, targetPath),
|
|
44
|
+
rootDir
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
49
|
+
const statusCode = error.code === 'ENOENT' ? 404 : 500;
|
|
50
|
+
res.status(statusCode).json({ error: message });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
router.get('/download', async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const relativePath = req.query.path;
|
|
56
|
+
if (!relativePath) {
|
|
57
|
+
return res.status(400).json({ error: 'Path parameter is required' });
|
|
58
|
+
}
|
|
59
|
+
const targetPath = path_1.default.join(rootDir, relativePath);
|
|
60
|
+
// Security: Ensure targetPath is within rootDir
|
|
61
|
+
if (!targetPath.startsWith(rootDir)) {
|
|
62
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
63
|
+
}
|
|
64
|
+
// Check if path exists and is a file
|
|
65
|
+
const stats = await fs_extra_1.default.stat(targetPath);
|
|
66
|
+
if (!stats.isFile()) {
|
|
67
|
+
return res.status(404).json({ error: 'File not found' });
|
|
68
|
+
}
|
|
69
|
+
// Stream file for download
|
|
70
|
+
res.download(targetPath, path_1.default.basename(targetPath));
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
const statusCode = error.code === 'ENOENT' ? 404 : 500;
|
|
75
|
+
res.status(statusCode).json({ error: message });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
router.get('/zip', async (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const relativePath = req.query.path;
|
|
81
|
+
if (!relativePath) {
|
|
82
|
+
return res.status(400).json({ error: 'Path parameter is required' });
|
|
83
|
+
}
|
|
84
|
+
const targetPath = path_1.default.join(rootDir, relativePath);
|
|
85
|
+
// Security: Ensure targetPath is within rootDir
|
|
86
|
+
if (!targetPath.startsWith(rootDir)) {
|
|
87
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
88
|
+
}
|
|
89
|
+
// Check if path exists and is a directory
|
|
90
|
+
const stats = await fs_extra_1.default.stat(targetPath);
|
|
91
|
+
if (!stats.isDirectory()) {
|
|
92
|
+
return res.status(404).json({ error: 'Directory not found' });
|
|
93
|
+
}
|
|
94
|
+
// Set headers for ZIP download
|
|
95
|
+
res.attachment(`${path_1.default.basename(targetPath)}.zip`);
|
|
96
|
+
// Create ZIP stream
|
|
97
|
+
const archive = (0, archiver_1.default)('zip', {
|
|
98
|
+
zlib: { level: 9 } // Maximum compression
|
|
99
|
+
});
|
|
100
|
+
archive.pipe(res);
|
|
101
|
+
archive.directory(targetPath, false);
|
|
102
|
+
await archive.finalize();
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
106
|
+
const statusCode = error.code === 'ENOENT' ? 404 : 500;
|
|
107
|
+
res.status(statusCode).json({ error: message });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
context.addRouter('/files', router);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
exports.default = plugin;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const FileBrowser = ({ apiBaseUrl }) => {
|
|
6
|
+
const [files, setFiles] = (0, react_1.useState)([]);
|
|
7
|
+
const [directories, setDirectories] = (0, react_1.useState)([]);
|
|
8
|
+
const [currentPath, setCurrentPath] = (0, react_1.useState)('');
|
|
9
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
10
|
+
const [loading, setLoading] = (0, react_1.useState)(true);
|
|
11
|
+
(0, react_1.useEffect)(() => {
|
|
12
|
+
console.debug('[File Browser UI] Fetching file list');
|
|
13
|
+
const fetchFiles = async () => {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(`${apiBaseUrl}/list?path=${encodeURIComponent(currentPath)}`);
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
const errorData = await response.json();
|
|
18
|
+
throw new Error(errorData.error || 'Failed to fetch files');
|
|
19
|
+
}
|
|
20
|
+
const data = await response.json();
|
|
21
|
+
setFiles(data.files);
|
|
22
|
+
setDirectories(data.directories);
|
|
23
|
+
setCurrentPath(data.currentPath);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error('[File Browser UI] Error fetching files:', err);
|
|
27
|
+
setError(err instanceof Error ? err.message : 'Failed to load files');
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
fetchFiles();
|
|
34
|
+
}, [currentPath, apiBaseUrl]);
|
|
35
|
+
const handleDirectoryClick = (dir) => {
|
|
36
|
+
setCurrentPath(currentPath ? `${currentPath}/${dir}` : dir);
|
|
37
|
+
};
|
|
38
|
+
if (loading) {
|
|
39
|
+
return (0, jsx_runtime_1.jsx)("div", { children: "Loading..." });
|
|
40
|
+
}
|
|
41
|
+
if (error) {
|
|
42
|
+
return (0, jsx_runtime_1.jsxs)("div", { children: ["File Browser: ", error] });
|
|
43
|
+
}
|
|
44
|
+
return ((0, jsx_runtime_1.jsxs)("div", { style: { padding: '16px', fontFamily: 'sans-serif' }, children: [(0, jsx_runtime_1.jsxs)("h3", { children: ["File Browser: ", currentPath || 'Root'] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h4", { children: "Directories" }), (0, jsx_runtime_1.jsx)("ul", { children: directories.map((dir) => ((0, jsx_runtime_1.jsxs)("li", { onClick: () => handleDirectoryClick(dir), style: { cursor: 'pointer', color: 'blue' }, children: ["\uD83D\uDCC1 ", dir] }, dir))) })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h4", { children: "Files" }), (0, jsx_runtime_1.jsx)("ul", { children: files.map((file) => ((0, jsx_runtime_1.jsxs)("li", { children: ["\uD83D\uDCC4 ", file] }, file))) })] })] }));
|
|
45
|
+
};
|
|
46
|
+
exports.default = FileBrowser;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jk3labs/paperclip-plugin-file-browser",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "A Paperclip plugin for browsing files in the default workspace",
|
|
5
|
-
"main": "index.js",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "jest",
|
|
8
8
|
"test:watch": "jest --watch",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
],
|
|
16
16
|
"author": "JKL",
|
|
17
17
|
"license": "ISC",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src/plugin.json"
|
|
21
|
+
],
|
|
18
22
|
"dependencies": {
|
|
19
23
|
"@types/archiver": "^7.0.0",
|
|
20
24
|
"archiver": "^8.0.0",
|
package/src/plugin.json
ADDED
package/IMPLEMENTATION_NOTES.md
DELETED
|
Binary file
|
package/index.js
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const express = require("express");
|
|
4
|
-
const fs = require("fs-extra");
|
|
5
|
-
const path = require("path");
|
|
6
|
-
|
|
7
|
-
module.exports = (pluginContext) => {
|
|
8
|
-
const router = express.Router();
|
|
9
|
-
|
|
10
|
-
// Dynamic root lookup: Use Paperclip's workspace root
|
|
11
|
-
const rootDir = pluginContext.workspaceRoot || process.env.PAPERCLIP_WORKSPACE_ROOT || process.cwd();
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* GET /files/list
|
|
15
|
-
* List files and directories at the given path
|
|
16
|
-
*/
|
|
17
|
-
router.get("/list", async (req, res) => {
|
|
18
|
-
const { dir } = req.query;
|
|
19
|
-
if (!dir) {
|
|
20
|
-
return res.status(400).json({ error: "Query parameter 'dir' is required" });
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const targetPath = path.join(rootDir, dir);
|
|
24
|
-
try {
|
|
25
|
-
const stats = await fs.stat(targetPath);
|
|
26
|
-
if (!stats.isDirectory()) {
|
|
27
|
-
return res.status(400).json({ error: "Path is not a directory" });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const items = await fs.readdir(targetPath);
|
|
31
|
-
const detailedItems = await Promise.all(
|
|
32
|
-
items.map(async (item) => {
|
|
33
|
-
const itemPath = path.join(targetPath, item);
|
|
34
|
-
const itemStats = await fs.stat(itemPath);
|
|
35
|
-
return {
|
|
36
|
-
name: item,
|
|
37
|
-
path: path.relative(rootDir, itemPath),
|
|
38
|
-
isDirectory: itemStats.isDirectory(),
|
|
39
|
-
size: itemStats.size,
|
|
40
|
-
modified: itemStats.mtime
|
|
41
|
-
};
|
|
42
|
-
})
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
res.json({
|
|
46
|
-
path: dir,
|
|
47
|
-
items: detailedItems
|
|
48
|
-
});
|
|
49
|
-
} catch (error) {
|
|
50
|
-
res.status(500).json({ error: error.message });
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* GET /files/read
|
|
56
|
-
* Read file content
|
|
57
|
-
*/
|
|
58
|
-
router.get("/read", async (req, res) => {
|
|
59
|
-
const { file } = req.query;
|
|
60
|
-
if (!file) {
|
|
61
|
-
return res.status(400).json({ error: "Query parameter 'file' is required" });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const filePath = path.join(rootDir, file);
|
|
65
|
-
try {
|
|
66
|
-
const stats = await fs.stat(filePath);
|
|
67
|
-
if (!stats.isFile()) {
|
|
68
|
-
return res.status(400).json({ error: "Path is not a file" });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const content = await fs.readFile(filePath, "utf-8");
|
|
72
|
-
res.json({
|
|
73
|
-
path: file,
|
|
74
|
-
content
|
|
75
|
-
});
|
|
76
|
-
} catch (error) {
|
|
77
|
-
res.status(500).json({ error: error.message });
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
return router;
|
|
82
|
-
};
|
package/jest.config.js
DELETED
package/manifest.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { PaperclipPluginManifestV1 } from '@paperclipai/shared';
|
|
2
|
-
|
|
3
|
-
export const manifest: PaperclipPluginManifestV1 = {
|
|
4
|
-
id: 'jkl.file-browser',
|
|
5
|
-
apiVersion: 1,
|
|
6
|
-
version: '1.0.0',
|
|
7
|
-
displayName: 'File Browser',
|
|
8
|
-
description: 'A plugin to browse files in the Paperclip default workspace.',
|
|
9
|
-
author: 'JKL <support@jkl.co>',
|
|
10
|
-
categories: ['utilities'],
|
|
11
|
-
capabilities: [],
|
|
12
|
-
entrypoints: {
|
|
13
|
-
worker: 'dist/index.js',
|
|
14
|
-
ui: 'dist/ui/FileBrowser.js',
|
|
15
|
-
},
|
|
16
|
-
};
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import express, { Router } from 'express';
|
|
2
|
-
import request from 'supertest';
|
|
3
|
-
import fs from 'fs-extra';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
|
|
6
|
-
// Mock the plugin router directly
|
|
7
|
-
const mockRouter = Router();
|
|
8
|
-
mockRouter.get('/list', (req, res) => {
|
|
9
|
-
const relativePath = req.query.path as string || '';
|
|
10
|
-
const mockRootDir = path.join(__dirname, 'mock-root');
|
|
11
|
-
const targetPath = path.join(mockRootDir, relativePath);
|
|
12
|
-
|
|
13
|
-
fs.pathExists(targetPath)
|
|
14
|
-
.then(exists => {
|
|
15
|
-
if (!exists) return res.status(404).json({ error: 'Directory not found' });
|
|
16
|
-
|
|
17
|
-
fs.readdir(targetPath, { withFileTypes: true })
|
|
18
|
-
.then(entries => {
|
|
19
|
-
const files = entries.filter(dirent => dirent.isFile()).map(dirent => dirent.name);
|
|
20
|
-
const directories = entries.filter(dirent => dirent.isDirectory()).map(dirent => dirent.name);
|
|
21
|
-
res.json({ files, directories, currentPath: relativePath });
|
|
22
|
-
})
|
|
23
|
-
.catch(() => res.status(500).json({ error: 'Failed to read directory' }));
|
|
24
|
-
})
|
|
25
|
-
.catch(() => res.status(500).json({ error: 'Server error' }));
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
mockRouter.get('/download', (req, res) => {
|
|
29
|
-
const relativePath = req.query.path as string;
|
|
30
|
-
if (!relativePath) return res.status(400).json({ error: 'Path parameter is required' });
|
|
31
|
-
|
|
32
|
-
const mockRootDir = path.join(__dirname, 'mock-root');
|
|
33
|
-
const targetPath = path.join(mockRootDir, relativePath);
|
|
34
|
-
|
|
35
|
-
fs.pathExists(targetPath)
|
|
36
|
-
.then(exists => {
|
|
37
|
-
if (!exists) return res.status(404).json({ error: 'File not found' });
|
|
38
|
-
|
|
39
|
-
fs.stat(targetPath)
|
|
40
|
-
.then(stats => {
|
|
41
|
-
if (!stats.isFile()) return res.status(404).json({ error: 'File not found' });
|
|
42
|
-
res.sendFile(targetPath);
|
|
43
|
-
})
|
|
44
|
-
.catch(() => res.status(500).json({ error: 'Failed to read file' }));
|
|
45
|
-
})
|
|
46
|
-
.catch(() => res.status(500).json({ error: 'Server error' }));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
mockRouter.get('/zip', (req, res) => {
|
|
50
|
-
const relativePath = req.query.path as string;
|
|
51
|
-
if (!relativePath) return res.status(400).json({ error: 'Path parameter is required' });
|
|
52
|
-
|
|
53
|
-
const mockRootDir = path.join(__dirname, 'mock-root');
|
|
54
|
-
const targetPath = path.join(mockRootDir, relativePath);
|
|
55
|
-
|
|
56
|
-
fs.pathExists(targetPath)
|
|
57
|
-
.then(exists => {
|
|
58
|
-
if (!exists) return res.status(404).json({ error: 'Directory not found' });
|
|
59
|
-
|
|
60
|
-
fs.stat(targetPath)
|
|
61
|
-
.then(stats => {
|
|
62
|
-
if (!stats.isDirectory()) return res.status(404).json({ error: 'Directory not found' });
|
|
63
|
-
res.status(500).json({ error: 'ZIP functionality not implemented in mock' });
|
|
64
|
-
})
|
|
65
|
-
.catch(() => res.status(500).json({ error: 'Failed to read directory' }));
|
|
66
|
-
})
|
|
67
|
-
.catch(() => res.status(500).json({ error: 'Server error' }));
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe('File Browser Plugin', () => {
|
|
71
|
-
let app: express.Express;
|
|
72
|
-
const mockRootDir = path.join(__dirname, 'mock-root');
|
|
73
|
-
const mockFilePath = path.join(mockRootDir, 'test.txt');
|
|
74
|
-
const mockDirPath = path.join(mockRootDir, 'test-dir');
|
|
75
|
-
|
|
76
|
-
beforeAll(async () => {
|
|
77
|
-
// Setup mock filesystem
|
|
78
|
-
await fs.ensureDir(mockRootDir);
|
|
79
|
-
await fs.writeFile(mockFilePath, 'test content');
|
|
80
|
-
await fs.ensureDir(mockDirPath);
|
|
81
|
-
await fs.writeFile(path.join(mockDirPath, 'nested.txt'), 'nested content');
|
|
82
|
-
|
|
83
|
-
// Setup Express app
|
|
84
|
-
app = express();
|
|
85
|
-
app.use('/files', mockRouter);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
afterAll(async () => {
|
|
89
|
-
// Cleanup mock filesystem
|
|
90
|
-
await fs.remove(mockRootDir);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe('GET /files/list', () => {
|
|
94
|
-
it('should list files and directories in root', async () => {
|
|
95
|
-
const response = await request(app).get('/files/list');
|
|
96
|
-
expect(response.status).toBe(200);
|
|
97
|
-
expect(response.body.files).toContain('test.txt');
|
|
98
|
-
expect(response.body.directories).toContain('test-dir');
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('should list files and directories in subdirectory', async () => {
|
|
102
|
-
const response = await request(app).get('/files/list?path=test-dir');
|
|
103
|
-
expect(response.status).toBe(200);
|
|
104
|
-
expect(response.body.files).toContain('nested.txt');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('should return 404 for non-existent directory', async () => {
|
|
108
|
-
const response = await request(app).get('/files/list?path=non-existent');
|
|
109
|
-
expect(response.status).toBe(404);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
describe('GET /files/download', () => {
|
|
114
|
-
it('should download a file', async () => {
|
|
115
|
-
const response = await request(app).get('/files/download?path=test.txt');
|
|
116
|
-
expect(response.status).toBe(200);
|
|
117
|
-
expect(response.text).toBe('test content');
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('should return 404 for non-existent file', async () => {
|
|
121
|
-
const response = await request(app).get('/files/download?path=non-existent.txt');
|
|
122
|
-
expect(response.status).toBe(404);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe('GET /files/zip', () => {
|
|
127
|
-
it('should return 404 for non-existent directory', async () => {
|
|
128
|
-
const response = await request(app).get('/files/zip?path=non-existent');
|
|
129
|
-
expect(response.status).toBe(404);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import fs from 'fs-extra';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import archiver from 'archiver';
|
|
5
|
-
|
|
6
|
-
interface PluginContext {
|
|
7
|
-
addRouter: (basePath: string, router: express.Router) => void;
|
|
8
|
-
environment: {
|
|
9
|
-
rootDir: string;
|
|
10
|
-
};
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const plugin = {
|
|
14
|
-
id: 'file-browser',
|
|
15
|
-
name: 'File Browser Plugin',
|
|
16
|
-
description: 'A plugin to browse, download, and zip files in the Paperclip default workspace.',
|
|
17
|
-
register: (context: PluginContext) => {
|
|
18
|
-
console.debug('[File Browser Plugin] Registering plugin');
|
|
19
|
-
const router = express.Router();
|
|
20
|
-
const rootDir = context.environment.rootDir;
|
|
21
|
-
|
|
22
|
-
// Validate rootDir exists
|
|
23
|
-
console.debug(`[File Browser Plugin] Root directory: ${rootDir}`);
|
|
24
|
-
if (!rootDir || !(fs.existsSync(rootDir))) {
|
|
25
|
-
console.error(`[File Browser Plugin] Invalid root directory: ${rootDir}`);
|
|
26
|
-
throw new Error(`Invalid root directory: ${rootDir}`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
router.get('/list', async (req, res) => {
|
|
30
|
-
try {
|
|
31
|
-
const relativePath = req.query.path as string || '';
|
|
32
|
-
const targetPath = path.join(rootDir, relativePath);
|
|
33
|
-
|
|
34
|
-
// Security: Ensure targetPath is within rootDir
|
|
35
|
-
if (!targetPath.startsWith(rootDir)) {
|
|
36
|
-
return res.status(403).json({ error: 'Access denied' });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
|
40
|
-
const files = entries
|
|
41
|
-
.filter(dirent => dirent.isFile())
|
|
42
|
-
.map(dirent => dirent.name);
|
|
43
|
-
const directories = entries
|
|
44
|
-
.filter(dirent => dirent.isDirectory())
|
|
45
|
-
.map(dirent => dirent.name);
|
|
46
|
-
|
|
47
|
-
res.json({
|
|
48
|
-
files,
|
|
49
|
-
directories,
|
|
50
|
-
currentPath: path.relative(rootDir, targetPath),
|
|
51
|
-
rootDir
|
|
52
|
-
});
|
|
53
|
-
} catch (error) {
|
|
54
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
-
const statusCode = (error as NodeJS.ErrnoException).code === 'ENOENT' ? 404 : 500;
|
|
56
|
-
res.status(statusCode).json({ error: message });
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
router.get('/download', async (req, res) => {
|
|
61
|
-
try {
|
|
62
|
-
const relativePath = req.query.path as string;
|
|
63
|
-
if (!relativePath) {
|
|
64
|
-
return res.status(400).json({ error: 'Path parameter is required' });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const targetPath = path.join(rootDir, relativePath);
|
|
68
|
-
|
|
69
|
-
// Security: Ensure targetPath is within rootDir
|
|
70
|
-
if (!targetPath.startsWith(rootDir)) {
|
|
71
|
-
return res.status(403).json({ error: 'Access denied' });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Check if path exists and is a file
|
|
75
|
-
const stats = await fs.stat(targetPath);
|
|
76
|
-
if (!stats.isFile()) {
|
|
77
|
-
return res.status(404).json({ error: 'File not found' });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Stream file for download
|
|
81
|
-
res.download(targetPath, path.basename(targetPath));
|
|
82
|
-
} catch (error) {
|
|
83
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
-
const statusCode = (error as NodeJS.ErrnoException).code === 'ENOENT' ? 404 : 500;
|
|
85
|
-
res.status(statusCode).json({ error: message });
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
router.get('/zip', async (req, res) => {
|
|
90
|
-
try {
|
|
91
|
-
const relativePath = req.query.path as string;
|
|
92
|
-
if (!relativePath) {
|
|
93
|
-
return res.status(400).json({ error: 'Path parameter is required' });
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const targetPath = path.join(rootDir, relativePath);
|
|
97
|
-
|
|
98
|
-
// Security: Ensure targetPath is within rootDir
|
|
99
|
-
if (!targetPath.startsWith(rootDir)) {
|
|
100
|
-
return res.status(403).json({ error: 'Access denied' });
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Check if path exists and is a directory
|
|
104
|
-
const stats = await fs.stat(targetPath);
|
|
105
|
-
if (!stats.isDirectory()) {
|
|
106
|
-
return res.status(404).json({ error: 'Directory not found' });
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Set headers for ZIP download
|
|
110
|
-
res.attachment(`${path.basename(targetPath)}.zip`);
|
|
111
|
-
|
|
112
|
-
// Create ZIP stream
|
|
113
|
-
const archive = archiver('zip', {
|
|
114
|
-
zlib: { level: 9 } // Maximum compression
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
archive.pipe(res);
|
|
118
|
-
archive.directory(targetPath, false);
|
|
119
|
-
await archive.finalize();
|
|
120
|
-
} catch (error) {
|
|
121
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
-
const statusCode = (error as NodeJS.ErrnoException).code === 'ENOENT' ? 404 : 500;
|
|
123
|
-
res.status(statusCode).json({ error: message });
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
context.addRouter('/files', router);
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
export default plugin;
|
package/src/ui/FileBrowser.tsx
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
2
|
-
|
|
3
|
-
interface FileBrowserProps {
|
|
4
|
-
apiBaseUrl: string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
interface FileListResponse {
|
|
8
|
-
files: string[];
|
|
9
|
-
directories: string[];
|
|
10
|
-
currentPath: string;
|
|
11
|
-
rootDir: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const FileBrowser: React.FC<FileBrowserProps> = ({ apiBaseUrl }) => {
|
|
15
|
-
const [files, setFiles] = useState<string[]>([]);
|
|
16
|
-
const [directories, setDirectories] = useState<string[]>([]);
|
|
17
|
-
const [currentPath, setCurrentPath] = useState<string>('');
|
|
18
|
-
const [error, setError] = useState<string | null>(null);
|
|
19
|
-
const [loading, setLoading] = useState<boolean>(true);
|
|
20
|
-
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
console.debug('[File Browser UI] Fetching file list');
|
|
23
|
-
const fetchFiles = async () => {
|
|
24
|
-
try {
|
|
25
|
-
const response = await fetch(`${apiBaseUrl}/list?path=${encodeURIComponent(currentPath)}`);
|
|
26
|
-
if (!response.ok) {
|
|
27
|
-
const errorData = await response.json();
|
|
28
|
-
throw new Error(errorData.error || 'Failed to fetch files');
|
|
29
|
-
}
|
|
30
|
-
const data: FileListResponse = await response.json();
|
|
31
|
-
setFiles(data.files);
|
|
32
|
-
setDirectories(data.directories);
|
|
33
|
-
setCurrentPath(data.currentPath);
|
|
34
|
-
} catch (err) {
|
|
35
|
-
console.error('[File Browser UI] Error fetching files:', err);
|
|
36
|
-
setError(err instanceof Error ? err.message : 'Failed to load files');
|
|
37
|
-
} finally {
|
|
38
|
-
setLoading(false);
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
fetchFiles();
|
|
43
|
-
}, [currentPath, apiBaseUrl]);
|
|
44
|
-
|
|
45
|
-
const handleDirectoryClick = (dir: string) => {
|
|
46
|
-
setCurrentPath(currentPath ? `${currentPath}/${dir}` : dir);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
if (loading) {
|
|
50
|
-
return <div>Loading...</div>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (error) {
|
|
54
|
-
return <div>File Browser: {error}</div>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<div style={{ padding: '16px', fontFamily: 'sans-serif' }}>
|
|
59
|
-
<h3>File Browser: {currentPath || 'Root'}</h3>
|
|
60
|
-
<div>
|
|
61
|
-
<h4>Directories</h4>
|
|
62
|
-
<ul>
|
|
63
|
-
{directories.map((dir) => (
|
|
64
|
-
<li key={dir} onClick={() => handleDirectoryClick(dir)} style={{ cursor: 'pointer', color: 'blue' }}>
|
|
65
|
-
📁 {dir}
|
|
66
|
-
</li>
|
|
67
|
-
))}
|
|
68
|
-
</ul>
|
|
69
|
-
</div>
|
|
70
|
-
<div>
|
|
71
|
-
<h4>Files</h4>
|
|
72
|
-
<ul>
|
|
73
|
-
{files.map((file) => (
|
|
74
|
-
<li key={file}>📄 {file}</li>
|
|
75
|
-
))}
|
|
76
|
-
</ul>
|
|
77
|
-
</div>
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
export default FileBrowser;
|
package/tsconfig.json
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2020",
|
|
4
|
-
"module": "commonjs",
|
|
5
|
-
"outDir": "./dist",
|
|
6
|
-
"rootDir": "./src",
|
|
7
|
-
"strict": true,
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"skipLibCheck": true,
|
|
10
|
-
"forceConsistentCasingInFileNames": true,
|
|
11
|
-
"jsx": "react-jsx",
|
|
12
|
-
"types": ["jest", "node"]
|
|
13
|
-
},
|
|
14
|
-
"include": ["src/**/*", "src/__tests__/**/*"],
|
|
15
|
-
"exclude": ["node_modules"]
|
|
16
|
-
}
|
|
File without changes
|