pinokiod 3.148.0 → 3.151.0
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/kernel/prototype.js +0 -1
- package/kernel/router/common.js +1 -0
- package/kernel/router/connector.js +2 -0
- package/kernel/shell.js +14 -14
- package/kernel/util.js +0 -1
- package/package.json +1 -1
- package/server/index.js +187 -53
- package/server/public/files-app/app.css +317 -0
- package/server/public/files-app/app.js +686 -0
- package/server/public/style.css +5 -5
- package/server/public/tab-idle-notifier.js +48 -6
- package/server/public/urldropdown.css +59 -0
- package/server/public/urldropdown.js +140 -90
- package/server/routes/files.js +284 -0
- package/server/views/app.ejs +75 -33
- package/server/views/file_browser.ejs +130 -0
- package/server/views/net.ejs +3 -4
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { isBinaryFile } = require('isbinaryfile');
|
|
5
|
+
|
|
6
|
+
const MAX_EDITABLE_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB safety limit
|
|
7
|
+
|
|
8
|
+
const createHttpError = (status, message) => {
|
|
9
|
+
const error = new Error(message);
|
|
10
|
+
error.status = status;
|
|
11
|
+
return error;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const sanitizeSegments = (value) => {
|
|
15
|
+
const normalized = path.posix.normalize(String(value || '').replace(/\\/g, '/'));
|
|
16
|
+
if (normalized === '.' || normalized === '') {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const segments = normalized.split('/').filter((segment) => segment && segment !== '.');
|
|
20
|
+
if (segments.some((segment) => segment === '..')) {
|
|
21
|
+
throw createHttpError(400, 'Invalid path');
|
|
22
|
+
}
|
|
23
|
+
return segments;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
module.exports = function registerFileRoutes(app, { kernel, getTheme, exists }) {
|
|
27
|
+
if (!app || !kernel) {
|
|
28
|
+
throw new Error('File routes require an express app and kernel instance');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const router = express.Router();
|
|
32
|
+
|
|
33
|
+
const asyncHandler = (fn) => (req, res, next) => {
|
|
34
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
const ensureWorkspace = async (workspaceParam, rootParam) => {
|
|
39
|
+
const apiRoot = kernel.path('api');
|
|
40
|
+
if (rootParam) {
|
|
41
|
+
let decodedRoot;
|
|
42
|
+
try {
|
|
43
|
+
decodedRoot = Buffer.from(String(rootParam), 'base64').toString('utf8');
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw createHttpError(400, 'Invalid workspace descriptor');
|
|
46
|
+
}
|
|
47
|
+
if (!decodedRoot) {
|
|
48
|
+
throw createHttpError(400, 'Invalid workspace descriptor');
|
|
49
|
+
}
|
|
50
|
+
const normalizedRoot = path.resolve(decodedRoot);
|
|
51
|
+
if (!path.isAbsolute(normalizedRoot)) {
|
|
52
|
+
throw createHttpError(400, 'Workspace path must be absolute');
|
|
53
|
+
}
|
|
54
|
+
const relativeToHome = path.relative(kernel.homedir, normalizedRoot);
|
|
55
|
+
if (relativeToHome.startsWith('..') || path.isAbsolute(relativeToHome)) {
|
|
56
|
+
throw createHttpError(400, 'Workspace outside Pinokio home');
|
|
57
|
+
}
|
|
58
|
+
const relativeToApi = path.relative(apiRoot, normalizedRoot);
|
|
59
|
+
if (relativeToApi.startsWith('..') || path.isAbsolute(relativeToApi)) {
|
|
60
|
+
throw createHttpError(400, 'Workspace outside api directory');
|
|
61
|
+
}
|
|
62
|
+
const existsResult = await exists(normalizedRoot);
|
|
63
|
+
if (!existsResult) {
|
|
64
|
+
throw createHttpError(404, 'Workspace not found');
|
|
65
|
+
}
|
|
66
|
+
const segments = sanitizeSegments(workspaceParam || relativeToApi);
|
|
67
|
+
const effectiveSegments = segments.length > 0 ? segments : sanitizeSegments(relativeToApi);
|
|
68
|
+
const slugSegments = effectiveSegments.length > 0 ? effectiveSegments : [path.basename(normalizedRoot)];
|
|
69
|
+
return {
|
|
70
|
+
apiRoot,
|
|
71
|
+
segments: slugSegments,
|
|
72
|
+
workspaceRoot: normalizedRoot,
|
|
73
|
+
workspaceLabel: slugSegments[slugSegments.length - 1],
|
|
74
|
+
workspaceSlug: slugSegments.join('/'),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!workspaceParam || typeof workspaceParam !== 'string') {
|
|
79
|
+
throw createHttpError(400, 'Missing workspace');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const segments = sanitizeSegments(workspaceParam);
|
|
83
|
+
if (segments.length === 0) {
|
|
84
|
+
throw createHttpError(400, 'Workspace path is required');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const workspaceRoot = path.resolve(apiRoot, ...segments);
|
|
88
|
+
const relativeToApi = path.relative(apiRoot, workspaceRoot);
|
|
89
|
+
if (relativeToApi.startsWith('..') || path.isAbsolute(relativeToApi)) {
|
|
90
|
+
throw createHttpError(400, 'Workspace outside api directory');
|
|
91
|
+
}
|
|
92
|
+
const existsResult = await exists(workspaceRoot);
|
|
93
|
+
if (!existsResult) {
|
|
94
|
+
throw createHttpError(404, 'Workspace not found');
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
apiRoot,
|
|
98
|
+
segments,
|
|
99
|
+
workspaceRoot,
|
|
100
|
+
workspaceLabel: segments[segments.length - 1],
|
|
101
|
+
workspaceSlug: segments.join('/'),
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const resolveWorkspacePath = async (workspaceParam, relativeParam = '', rootParam) => {
|
|
106
|
+
const workspaceInfo = await ensureWorkspace(workspaceParam, rootParam);
|
|
107
|
+
const relativeSegments = sanitizeSegments(relativeParam);
|
|
108
|
+
const absolutePath = path.resolve(workspaceInfo.workspaceRoot, ...relativeSegments);
|
|
109
|
+
const relativeToWorkspace = path.relative(workspaceInfo.workspaceRoot, absolutePath);
|
|
110
|
+
if (relativeToWorkspace.startsWith('..') || path.isAbsolute(relativeToWorkspace)) {
|
|
111
|
+
throw createHttpError(400, 'Path escapes workspace');
|
|
112
|
+
}
|
|
113
|
+
const relativePosix = relativeSegments.join('/');
|
|
114
|
+
return {
|
|
115
|
+
...workspaceInfo,
|
|
116
|
+
absolutePath,
|
|
117
|
+
relativeSegments,
|
|
118
|
+
relativePosix,
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
router.get('/pinokio/fileview/*', asyncHandler(async (req, res) => {
|
|
123
|
+
const workspaceParam = req.params[0] || '';
|
|
124
|
+
const initialRelative = req.query.path || '';
|
|
125
|
+
const { workspaceRoot, workspaceLabel, workspaceSlug, relativePosix, absolutePath } = await resolveWorkspacePath(workspaceParam, initialRelative);
|
|
126
|
+
const initialPosixPath = relativePosix;
|
|
127
|
+
const initialStats = await fs.promises.stat(absolutePath).catch(() => null);
|
|
128
|
+
const initialType = initialStats ? (initialStats.isDirectory() ? 'directory' : initialStats.isFile() ? 'file' : null) : null;
|
|
129
|
+
|
|
130
|
+
const workspaceMeta = {
|
|
131
|
+
title: '',
|
|
132
|
+
description: '',
|
|
133
|
+
icon: '',
|
|
134
|
+
iconpath: ''
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
const meta = await kernel.api.meta(workspaceSlug);
|
|
138
|
+
if (meta && typeof meta === 'object') {
|
|
139
|
+
workspaceMeta.title = typeof meta.title === 'string' ? meta.title : '';
|
|
140
|
+
workspaceMeta.description = typeof meta.description === 'string' ? meta.description : '';
|
|
141
|
+
workspaceMeta.icon = typeof meta.icon === 'string' ? meta.icon : '';
|
|
142
|
+
workspaceMeta.iconpath = typeof meta.iconpath === 'string' ? meta.iconpath : '';
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Metadata is optional for the file editor; ignore failures.
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const workspaceRootEncoded = Buffer.from(workspaceRoot).toString('base64');
|
|
149
|
+
res.render('file_browser', {
|
|
150
|
+
theme: getTheme ? getTheme() : 'light',
|
|
151
|
+
agent: req.agent,
|
|
152
|
+
workspace: workspaceSlug,
|
|
153
|
+
workspaceLabel,
|
|
154
|
+
workspaceRoot,
|
|
155
|
+
workspaceSlug,
|
|
156
|
+
workspaceRootEncoded,
|
|
157
|
+
initialPath: initialPosixPath,
|
|
158
|
+
initialPathType: initialType,
|
|
159
|
+
workspaceMeta
|
|
160
|
+
});
|
|
161
|
+
}));
|
|
162
|
+
|
|
163
|
+
router.get('/api/files/list', asyncHandler(async (req, res) => {
|
|
164
|
+
const { workspace, path: relativeQuery } = req.query;
|
|
165
|
+
const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativeQuery, req.query.root);
|
|
166
|
+
|
|
167
|
+
const stats = await fs.promises.stat(absolutePath).catch(() => null);
|
|
168
|
+
if (!stats) {
|
|
169
|
+
throw createHttpError(404, 'Directory not found');
|
|
170
|
+
}
|
|
171
|
+
if (!stats.isDirectory()) {
|
|
172
|
+
throw createHttpError(400, 'Path must be a directory');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const entries = await fs.promises.readdir(absolutePath, { withFileTypes: true }).catch(() => []);
|
|
176
|
+
const sorted = entries.sort((a, b) => {
|
|
177
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
178
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
179
|
+
return a.name.localeCompare(b.name);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const items = await Promise.all(sorted.map(async (dirent) => {
|
|
183
|
+
const entrySegments = [...(relativePosix ? relativePosix.split('/') : []), dirent.name];
|
|
184
|
+
const entryPosix = entrySegments.filter(Boolean).join('/');
|
|
185
|
+
let hasChildren = false;
|
|
186
|
+
if (dirent.isDirectory()) {
|
|
187
|
+
const candidatePath = path.resolve(absolutePath, dirent.name);
|
|
188
|
+
try {
|
|
189
|
+
const childDir = await fs.promises.opendir(candidatePath);
|
|
190
|
+
let count = 0;
|
|
191
|
+
for await (const child of childDir) {
|
|
192
|
+
count += 1;
|
|
193
|
+
if (count > 0) {
|
|
194
|
+
hasChildren = true;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
await childDir.close();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
hasChildren = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
name: dirent.name,
|
|
205
|
+
path: entryPosix,
|
|
206
|
+
type: dirent.isDirectory() ? 'directory' : 'file',
|
|
207
|
+
workspace: workspaceSlug,
|
|
208
|
+
hasChildren,
|
|
209
|
+
};
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
res.json({
|
|
213
|
+
workspace: workspaceSlug,
|
|
214
|
+
path: relativePosix,
|
|
215
|
+
entries: items,
|
|
216
|
+
});
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
router.get('/api/files/read', asyncHandler(async (req, res) => {
|
|
220
|
+
const { workspace, path: relativeQuery } = req.query;
|
|
221
|
+
const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativeQuery, req.query.root);
|
|
222
|
+
|
|
223
|
+
const stats = await fs.promises.stat(absolutePath).catch(() => null);
|
|
224
|
+
if (!stats) {
|
|
225
|
+
throw createHttpError(404, 'File not found');
|
|
226
|
+
}
|
|
227
|
+
if (!stats.isFile()) {
|
|
228
|
+
throw createHttpError(400, 'Path must be a file');
|
|
229
|
+
}
|
|
230
|
+
const metaOnly = Object.prototype.hasOwnProperty.call(req.query, 'meta');
|
|
231
|
+
if (metaOnly) {
|
|
232
|
+
res.json({
|
|
233
|
+
workspace: workspaceSlug,
|
|
234
|
+
path: relativePosix,
|
|
235
|
+
size: stats.size,
|
|
236
|
+
mtime: stats.mtimeMs,
|
|
237
|
+
meta: true,
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (stats.size > MAX_EDITABLE_FILE_SIZE_BYTES) {
|
|
242
|
+
throw createHttpError(413, 'File is too large to open in the editor');
|
|
243
|
+
}
|
|
244
|
+
const isBinary = await isBinaryFile(absolutePath);
|
|
245
|
+
if (isBinary) {
|
|
246
|
+
throw createHttpError(415, 'Binary files cannot be opened in the editor');
|
|
247
|
+
}
|
|
248
|
+
const content = await fs.promises.readFile(absolutePath, 'utf8');
|
|
249
|
+
res.json({
|
|
250
|
+
workspace: workspaceSlug,
|
|
251
|
+
path: relativePosix,
|
|
252
|
+
content,
|
|
253
|
+
size: stats.size,
|
|
254
|
+
mtime: stats.mtimeMs,
|
|
255
|
+
});
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
router.post('/api/files/save', asyncHandler(async (req, res) => {
|
|
259
|
+
const { workspace, path: relativePath, content, root: rootParam } = req.body || {};
|
|
260
|
+
if (typeof content !== 'string') {
|
|
261
|
+
throw createHttpError(400, 'File content must be a string');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativePath, rootParam);
|
|
265
|
+
const stats = await fs.promises.stat(absolutePath).catch(() => null);
|
|
266
|
+
if (!stats) {
|
|
267
|
+
throw createHttpError(404, 'File not found');
|
|
268
|
+
}
|
|
269
|
+
if (!stats.isFile()) {
|
|
270
|
+
throw createHttpError(400, 'Path must be a file');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await fs.promises.writeFile(absolutePath, content, 'utf8');
|
|
274
|
+
const updatedStats = await fs.promises.stat(absolutePath);
|
|
275
|
+
res.json({
|
|
276
|
+
workspace: workspaceSlug,
|
|
277
|
+
path: relativePosix,
|
|
278
|
+
size: updatedStats.size,
|
|
279
|
+
mtime: updatedStats.mtimeMs,
|
|
280
|
+
});
|
|
281
|
+
}));
|
|
282
|
+
|
|
283
|
+
app.use(router);
|
|
284
|
+
};
|
package/server/views/app.ejs
CHANGED
|
@@ -22,6 +22,9 @@ body.dark #devtab.selected {
|
|
|
22
22
|
border: none;
|
|
23
23
|
background: rgba(255,255,255,0.07);
|
|
24
24
|
}
|
|
25
|
+
#editortab {
|
|
26
|
+
color: #7f5bf3;
|
|
27
|
+
}
|
|
25
28
|
#devtab {
|
|
26
29
|
align-items: center;
|
|
27
30
|
justify-content: center;
|
|
@@ -393,10 +396,36 @@ body.dark .appcanvas > aside .header-item.btn:not(.selected) {
|
|
|
393
396
|
.appcanvas > aside .header-item .tab {
|
|
394
397
|
display: flex;
|
|
395
398
|
align-items: center;
|
|
399
|
+
/*
|
|
396
400
|
gap: 6px;
|
|
401
|
+
*/
|
|
397
402
|
flex: 1;
|
|
398
403
|
min-width: 0;
|
|
399
404
|
}
|
|
405
|
+
.appcanvas > aside .header-item .tab .tab-metric {
|
|
406
|
+
display: inline-flex;
|
|
407
|
+
align-items: baseline;
|
|
408
|
+
gap: 6px;
|
|
409
|
+
font-size: 11px;
|
|
410
|
+
white-space: nowrap;
|
|
411
|
+
flex: 0 0 auto;
|
|
412
|
+
padding-left: 6px;
|
|
413
|
+
}
|
|
414
|
+
.appcanvas > aside .header-item .tab .tab-metric__label {
|
|
415
|
+
text-transform: uppercase;
|
|
416
|
+
letter-spacing: 0.06em;
|
|
417
|
+
font-size: 10px;
|
|
418
|
+
opacity: 0.7;
|
|
419
|
+
}
|
|
420
|
+
.appcanvas > aside .header-item .tab .tab-metric__value {
|
|
421
|
+
font-weight: 600;
|
|
422
|
+
font-size: 11px;
|
|
423
|
+
}
|
|
424
|
+
.appcanvas > aside .header-item .tab .tab-metric .disk-usage {
|
|
425
|
+
flex: 0 0 auto;
|
|
426
|
+
min-width: 0;
|
|
427
|
+
text-align: right;
|
|
428
|
+
}
|
|
400
429
|
|
|
401
430
|
.appcanvas > aside .header-item:hover {
|
|
402
431
|
background: var(--pinokio-sidebar-tab-hover);
|
|
@@ -1261,6 +1290,7 @@ body.dark .submenu {
|
|
|
1261
1290
|
flex-grow: 1;
|
|
1262
1291
|
font-weight: bold;
|
|
1263
1292
|
text-align: right;
|
|
1293
|
+
opacity: 0.5;
|
|
1264
1294
|
}
|
|
1265
1295
|
.disk-usage i {
|
|
1266
1296
|
margin-right: 5px;
|
|
@@ -1426,8 +1456,8 @@ body.dark #fs-status {
|
|
|
1426
1456
|
|
|
1427
1457
|
.fs-status-dropdown.git-fork .fs-dropdown-menu,
|
|
1428
1458
|
.fs-status-dropdown.git-publish .fs-dropdown-menu {
|
|
1429
|
-
left:
|
|
1430
|
-
right:
|
|
1459
|
+
left: 0;
|
|
1460
|
+
right: auto;
|
|
1431
1461
|
width: min(420px, calc(100vw - 24px));
|
|
1432
1462
|
max-width: min(420px, calc(100vw - 24px));
|
|
1433
1463
|
white-space: normal;
|
|
@@ -1504,9 +1534,6 @@ body.dark #fs-status .fs-dropdown-menu .frame-link:hover {
|
|
|
1504
1534
|
background: rgba(148, 163, 184, 0.15);
|
|
1505
1535
|
}
|
|
1506
1536
|
|
|
1507
|
-
#fs-status .git-changes {
|
|
1508
|
-
margin-left: auto;
|
|
1509
|
-
}
|
|
1510
1537
|
|
|
1511
1538
|
#fs-changes-menu .fs-dropdown-empty {
|
|
1512
1539
|
padding: 8px 14px;
|
|
@@ -1515,8 +1542,8 @@ body.dark #fs-status .fs-dropdown-menu .frame-link:hover {
|
|
|
1515
1542
|
}
|
|
1516
1543
|
|
|
1517
1544
|
#fs-changes-menu {
|
|
1518
|
-
left:
|
|
1519
|
-
right:
|
|
1545
|
+
left: 0;
|
|
1546
|
+
right: auto;
|
|
1520
1547
|
width: max-content;
|
|
1521
1548
|
max-width: min(460px, calc(100vw - 32px));
|
|
1522
1549
|
max-height: min(70vh, 420px);
|
|
@@ -2900,6 +2927,15 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
|
|
|
2900
2927
|
<div class='loader'><i class='fa-solid fa-angle-right'></i></div>
|
|
2901
2928
|
-->
|
|
2902
2929
|
</a>
|
|
2930
|
+
<a id='editortab' data-mode="refresh" target="<%=editor_tab%>" href="<%=editor_tab%>" class="btn header-item frame-link edit-tab" data-index="11">
|
|
2931
|
+
<div class='tab'>
|
|
2932
|
+
<i class="fa-solid fa-file-lines"></i>
|
|
2933
|
+
<div class='display'>Files</div>
|
|
2934
|
+
<div class='tab-metric'>
|
|
2935
|
+
<span class='disk-usage tab-metric__value' data-path="/">--</span>
|
|
2936
|
+
</div>
|
|
2937
|
+
</div>
|
|
2938
|
+
</a>
|
|
2903
2939
|
<div class="dynamic <%=type==='run' ? '' : 'selected'%>">
|
|
2904
2940
|
<div class='submenu'>
|
|
2905
2941
|
<% if (plugin_menu) { %>
|
|
@@ -2914,6 +2950,13 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
|
|
|
2914
2950
|
<% if (config.menu) { %>
|
|
2915
2951
|
<%- include('./partials/menu', { menu: config.menu, }) %>
|
|
2916
2952
|
<% } %>
|
|
2953
|
+
<a id='settings-tab' data-mode="refresh" target="env-settings" href="/env/api/<%=name%>" class="btn header-item frame-link" data-index="settings" data-static="retain">
|
|
2954
|
+
<div class='tab'>
|
|
2955
|
+
<i class="fa-solid fa-gear"></i>
|
|
2956
|
+
<div class='display'>Settings</div>
|
|
2957
|
+
<div class='flexible'></div>
|
|
2958
|
+
</div>
|
|
2959
|
+
</a>
|
|
2917
2960
|
</div>
|
|
2918
2961
|
<% } %>
|
|
2919
2962
|
<div class='m s temp-menu' data-type='s'>
|
|
@@ -2933,27 +2976,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
|
|
|
2933
2976
|
<div class='container'>
|
|
2934
2977
|
<% if (type === "browse") { %>
|
|
2935
2978
|
<div id='fs-status' data-workspace="<%=name%>" data-create-uri="<%=git_create_url%>" data-history-uri="<%=git_history_url%>" data-status-uri="<%=git_status_url%>" data-uri="<%=git_monitor_url%>" data-push-uri="<%=git_push_url%>" data-fork-uri="<%=git_fork_url%>">
|
|
2936
|
-
<a target="<%=src%>" href="<%=src%>" class='fs-status-btn frame-link' data-index="0" data-mode="refresh" data-type="n">
|
|
2937
|
-
<span class='fs-status-label'>
|
|
2938
|
-
<i class="fa-regular fa-folder-open"></i>
|
|
2939
|
-
Files |
|
|
2940
|
-
<div class='disk-usage' data-path="/"></div>
|
|
2941
|
-
</span>
|
|
2942
|
-
</a>
|
|
2943
|
-
<div class='fs-status-dropdown'>
|
|
2944
|
-
<button id='edit-toggle' class='fs-status-btn revealer' data-group='#fs-edit-menu' type='button'>
|
|
2945
|
-
<span class='fs-status-label'>
|
|
2946
|
-
<i class='fa-solid fa-pen-to-square'></i>
|
|
2947
|
-
Edit
|
|
2948
|
-
</span>
|
|
2949
|
-
</button>
|
|
2950
|
-
<div class='fs-dropdown-menu submenu hidden' id='fs-edit-menu'>
|
|
2951
|
-
<button type='button' class='fs-dropdown-item' id='edit'><i class="fa-solid fa-pencil"></i> Edit</button>
|
|
2952
|
-
<button type='button' class='fs-dropdown-item' id='move'><i class="fa-solid fa-right-to-bracket"></i> Move</button>
|
|
2953
|
-
<button type='button' class='fs-dropdown-item' id='copy'><i class="fa-solid fa-copy"></i> Copy</button>
|
|
2954
|
-
<button type='button' class='fs-dropdown-item' id='delete' data-name="<%=name%>"><i class="fa-solid fa-trash-can"></i> Delete</button>
|
|
2955
|
-
</div>
|
|
2956
|
-
</div>
|
|
2957
2979
|
<!--
|
|
2958
2980
|
<div class='fs-status-dropdown nested-menu git blue'>
|
|
2959
2981
|
<button type='button' class='fs-status-btn frame-link reveal'>
|
|
@@ -2966,11 +2988,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
|
|
|
2966
2988
|
</div>
|
|
2967
2989
|
</div>
|
|
2968
2990
|
-->
|
|
2969
|
-
<div class='fs-status-dropdown'>
|
|
2970
|
-
<a target="/env/api/<%=name%>" href="/env/api/<%=name%>" class='fs-status-btn frame-link selected' data-index="3" data-mode="refresh" data-type="n">
|
|
2971
|
-
<span class='fs-status-label'><i class='fa-solid fa-gear'></i> Settings</span>
|
|
2972
|
-
</a>
|
|
2973
|
-
</div>
|
|
2974
2991
|
<div class='fs-status-dropdown git-changes'>
|
|
2975
2992
|
<button id='fs-changes-btn' class='fs-status-btn revealer' data-group='#fs-changes-menu' type='button'>
|
|
2976
2993
|
<span class='fs-status-label'><i class="fa-solid fa-code-compare"></i> Changes</span>
|
|
@@ -4842,6 +4859,9 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
|
|
|
4842
4859
|
if (!targetContainer) {
|
|
4843
4860
|
return
|
|
4844
4861
|
}
|
|
4862
|
+
const staticNodes = Array.from(targetContainer.children || []).filter((node) => {
|
|
4863
|
+
return node && node.dataset && node.dataset.static === 'retain'
|
|
4864
|
+
})
|
|
4845
4865
|
const template = document.createElement('template')
|
|
4846
4866
|
template.innerHTML = html || ''
|
|
4847
4867
|
const fragment = template.content
|
|
@@ -4857,6 +4877,28 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
|
|
|
4857
4877
|
}
|
|
4858
4878
|
const nodes = Array.from(fragment.childNodes)
|
|
4859
4879
|
targetContainer.replaceChildren(...nodes)
|
|
4880
|
+
if (staticNodes.length > 0) {
|
|
4881
|
+
staticNodes.forEach((node) => {
|
|
4882
|
+
if (!node) {
|
|
4883
|
+
return
|
|
4884
|
+
}
|
|
4885
|
+
if (node.parentElement === targetContainer) {
|
|
4886
|
+
return
|
|
4887
|
+
}
|
|
4888
|
+
if (node.dataset && node.dataset.static) {
|
|
4889
|
+
const duplicates = Array.from(targetContainer.children || []).filter((candidate) => {
|
|
4890
|
+
if (candidate === node) {
|
|
4891
|
+
return false
|
|
4892
|
+
}
|
|
4893
|
+
return candidate.dataset && candidate.dataset.static === node.dataset.static
|
|
4894
|
+
})
|
|
4895
|
+
duplicates.forEach((duplicate) => {
|
|
4896
|
+
duplicate.remove()
|
|
4897
|
+
})
|
|
4898
|
+
}
|
|
4899
|
+
targetContainer.appendChild(node)
|
|
4900
|
+
})
|
|
4901
|
+
}
|
|
4860
4902
|
}
|
|
4861
4903
|
let timestampIntervalId = null
|
|
4862
4904
|
const ensureTimestampInterval = () => {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
6
|
+
<title>Files · <%=workspaceLabel%></title>
|
|
7
|
+
<link href="/css/fontawesome.min.css" rel="stylesheet">
|
|
8
|
+
<link href="/css/solid.min.css" rel="stylesheet">
|
|
9
|
+
<link href="/css/regular.min.css" rel="stylesheet">
|
|
10
|
+
<link href="/css/brands.min.css" rel="stylesheet">
|
|
11
|
+
<link href="/style.css" rel="stylesheet">
|
|
12
|
+
<link href="/files-app/app.css" rel="stylesheet">
|
|
13
|
+
<link href="/filepond.min.css" rel="stylesheet" />
|
|
14
|
+
<link href="/filepond-plugin-image-preview.min.css" rel="stylesheet" />
|
|
15
|
+
<link href="/filepond-plugin-image-edit.min.css" rel="stylesheet" />
|
|
16
|
+
<link href="/noty.css" rel="stylesheet">
|
|
17
|
+
<% if (agent === 'electron') { %>
|
|
18
|
+
<link href="/electron.css" rel="stylesheet">
|
|
19
|
+
<% } %>
|
|
20
|
+
</head>
|
|
21
|
+
<body class="<%=theme%>" data-agent="<%=agent%>">
|
|
22
|
+
<div class="files-app" data-theme="<%=theme%>">
|
|
23
|
+
<header class="files-app__header">
|
|
24
|
+
<div class="files-app__header-title">
|
|
25
|
+
<i class="fa-regular fa-folder-open"></i>
|
|
26
|
+
<div class="files-app__header-text">
|
|
27
|
+
<div class="files-app__header-primary"><%=workspaceLabel%></div>
|
|
28
|
+
<div class="files-app__header-sub"><%=workspaceRoot%></div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="files-app__header-actions">
|
|
32
|
+
<button class="files-app__meta" type="button" id="files-app-edit-metadata">
|
|
33
|
+
<i class="fa-solid fa-pen-to-square"></i>
|
|
34
|
+
<span>Edit metadata</span>
|
|
35
|
+
</button>
|
|
36
|
+
<button class="files-app__save" id="files-app-save" disabled>
|
|
37
|
+
<i class="fa-solid fa-floppy-disk"></i>
|
|
38
|
+
<span>Save</span>
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</header>
|
|
42
|
+
<section class="files-app__body">
|
|
43
|
+
<aside class="files-app__sidebar" aria-label="Project files">
|
|
44
|
+
<div class="files-app__sidebar-scroll" id="files-app-tree">
|
|
45
|
+
</div>
|
|
46
|
+
</aside>
|
|
47
|
+
<main class="files-app__main">
|
|
48
|
+
<div class="files-app__tabs" id="files-app-tabs" role="tablist" aria-label="Open files"></div>
|
|
49
|
+
<div class="files-app__editor" id="files-app-editor" role="presentation"></div>
|
|
50
|
+
<div class="files-app__status" id="files-app-status" role="status" aria-live="polite"></div>
|
|
51
|
+
</main>
|
|
52
|
+
</section>
|
|
53
|
+
</div>
|
|
54
|
+
<script src="/sweetalert2.js"></script>
|
|
55
|
+
<script src="/filepond-plugin-file-validate-type.min.js"></script>
|
|
56
|
+
<script src="/filepond-plugin-image-exif-orientation.min.js"></script>
|
|
57
|
+
<script src="/filepond-plugin-image-preview.min.js"></script>
|
|
58
|
+
<script src="/filepond-plugin-image-edit.min.js"></script>
|
|
59
|
+
<script src="/filepond-plugin-image-crop.min.js"></script>
|
|
60
|
+
<script src="/filepond-plugin-image-resize.min.js"></script>
|
|
61
|
+
<script src="/filepond-plugin-image-transform.min.js"></script>
|
|
62
|
+
<script src="/filepond.min.js"></script>
|
|
63
|
+
<script src="/fseditor.js"></script>
|
|
64
|
+
<script src="/ace/ace.js"></script>
|
|
65
|
+
<script src="/ace/ext-modelist.js"></script>
|
|
66
|
+
<script src="/files-app/app.js"></script>
|
|
67
|
+
<script>
|
|
68
|
+
window.FilesApp.init({
|
|
69
|
+
workspace: <%- JSON.stringify(workspaceSlug) %>,
|
|
70
|
+
workspaceLabel: <%- JSON.stringify(workspaceLabel) %>,
|
|
71
|
+
theme: <%- JSON.stringify(theme) %>,
|
|
72
|
+
initialPath: <%- JSON.stringify(initialPath || '') %>,
|
|
73
|
+
initialPathType: <%- JSON.stringify(initialPathType || null) %>,
|
|
74
|
+
workspaceRoot: <%- JSON.stringify(workspaceRootEncoded || '') %>
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const workspaceMetadata = {
|
|
78
|
+
title: <%- JSON.stringify((workspaceMeta && workspaceMeta.title) || '') %>,
|
|
79
|
+
description: <%- JSON.stringify((workspaceMeta && workspaceMeta.description) || '') %>,
|
|
80
|
+
icon: <%- JSON.stringify((workspaceMeta && workspaceMeta.icon) || '') %>,
|
|
81
|
+
iconpath: <%- JSON.stringify((workspaceMeta && workspaceMeta.iconpath) || '') %>
|
|
82
|
+
};
|
|
83
|
+
const workspaceSlugValue = <%- JSON.stringify(workspaceSlug) %>;
|
|
84
|
+
|
|
85
|
+
const encodeWorkspacePath = (value) => {
|
|
86
|
+
if (!value) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
return value
|
|
90
|
+
.split('/')
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.map((segment) => encodeURIComponent(segment))
|
|
93
|
+
.join('/');
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const redirectToWorkspace = (newPath) => {
|
|
97
|
+
if (!newPath) {
|
|
98
|
+
return window.location.href;
|
|
99
|
+
}
|
|
100
|
+
const encoded = encodeWorkspacePath(newPath);
|
|
101
|
+
const url = new URL(window.location.href);
|
|
102
|
+
url.pathname = `/pinokio/fileview/${encoded}`;
|
|
103
|
+
return url.toString();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const metadataButton = document.getElementById('files-app-edit-metadata');
|
|
107
|
+
if (metadataButton) {
|
|
108
|
+
metadataButton.addEventListener('click', async (event) => {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
if (typeof FSEditor !== 'function') {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await FSEditor({
|
|
115
|
+
title: workspaceMetadata.title,
|
|
116
|
+
description: workspaceMetadata.description,
|
|
117
|
+
old_path: workspaceSlugValue,
|
|
118
|
+
icon: workspaceMetadata.icon,
|
|
119
|
+
iconpath: workspaceMetadata.iconpath,
|
|
120
|
+
edit: true,
|
|
121
|
+
redirect: redirectToWorkspace
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error('Failed to open metadata editor', error);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
</body>
|
|
130
|
+
</html>
|
package/server/views/net.ejs
CHANGED
|
@@ -704,10 +704,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
704
704
|
<% item.external_router.forEach((domain) => { %>
|
|
705
705
|
<a class='net' target="_blank" href="https://<%=domain%>"><span class='mark'>HTTPS</span><span><%=domain%></span></a>
|
|
706
706
|
<% }) %>
|
|
707
|
-
<% }
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
<% } %>
|
|
707
|
+
<% } %>
|
|
708
|
+
<% if (item.external_ip) { %>
|
|
709
|
+
<a class='net' target="_blank" href="http://<%=item.external_ip%>"><span class='mark'>HTTP</span><span><%=item.external_ip%></span></a>
|
|
711
710
|
<% } %>
|
|
712
711
|
</div>
|
|
713
712
|
</div>
|