pinokiod 3.147.0 → 3.150.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/shell.js +14 -14
- package/kernel/util.js +0 -1
- package/package.json +1 -1
- package/server/index.js +17 -4
- 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/terminal_input_tracker.js +5 -3
- 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/terminal.ejs +6 -4
package/server/public/style.css
CHANGED
|
@@ -284,15 +284,16 @@ body.dark .navheader2 {
|
|
|
284
284
|
background: rgba(255,255,255,0.07) !important;
|
|
285
285
|
}
|
|
286
286
|
body.dark .navheader2 .btn {
|
|
287
|
-
background: rgba(
|
|
287
|
+
background: rgba(255,255,255,0.1);
|
|
288
288
|
}
|
|
289
289
|
.navheader2 {
|
|
290
|
-
|
|
291
|
-
padding: 5px 0;
|
|
290
|
+
padding: 10px 0 10px;
|
|
292
291
|
/*
|
|
293
292
|
background: white;
|
|
294
293
|
*/
|
|
294
|
+
/*
|
|
295
295
|
background: #F1F1F1 !important;
|
|
296
|
+
*/
|
|
296
297
|
}
|
|
297
298
|
.navheader {
|
|
298
299
|
backdrop-filter: blur(16px);
|
|
@@ -926,7 +927,6 @@ body.columns .containers {
|
|
|
926
927
|
height: 100%;
|
|
927
928
|
}
|
|
928
929
|
.menu-btns {
|
|
929
|
-
display: flex;
|
|
930
930
|
align-items: flex-start;
|
|
931
931
|
flex-wrap: wrap;
|
|
932
932
|
}
|
|
@@ -2092,7 +2092,7 @@ body.dark header .home {
|
|
|
2092
2092
|
}
|
|
2093
2093
|
header .home {
|
|
2094
2094
|
color: var(--light-color);
|
|
2095
|
-
padding:
|
|
2095
|
+
padding: 10px;
|
|
2096
2096
|
cursor: pointer !important;
|
|
2097
2097
|
}
|
|
2098
2098
|
/*
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const FRAME_LINK_SELECTOR = '.frame-link';
|
|
11
11
|
const LIVE_CLASS = 'is-live';
|
|
12
12
|
const MAX_MESSAGE_PREVIEW = 140;
|
|
13
|
+
const MIN_COMMAND_DURATION_MS = 2000;
|
|
13
14
|
|
|
14
15
|
const tabStates = new Map();
|
|
15
16
|
const observedIndicators = new WeakSet();
|
|
@@ -135,7 +136,9 @@
|
|
|
135
136
|
isLive: false,
|
|
136
137
|
notified: false,
|
|
137
138
|
lastInput: '',
|
|
139
|
+
commandStartTimestamp: 0,
|
|
138
140
|
lastLiveTimestamp: 0,
|
|
141
|
+
lastActivityTimestamp: 0,
|
|
139
142
|
notifyEnabled: getPreference(frameName),
|
|
140
143
|
};
|
|
141
144
|
tabStates.set(frameName, state);
|
|
@@ -449,7 +452,7 @@ const ensureTabAccessories = aggregateDebounce(() => {
|
|
|
449
452
|
if (observedIndicators.has(node)) {
|
|
450
453
|
return;
|
|
451
454
|
}
|
|
452
|
-
indicatorObserver.observe(node, { attributes: true, attributeFilter: ['class'] });
|
|
455
|
+
indicatorObserver.observe(node, { attributes: true, attributeFilter: ['class', 'data-timestamp'] });
|
|
453
456
|
observedIndicators.add(node);
|
|
454
457
|
log('Observing indicator', node);
|
|
455
458
|
});
|
|
@@ -516,7 +519,17 @@ const ensureTabAccessories = aggregateDebounce(() => {
|
|
|
516
519
|
return true;
|
|
517
520
|
};
|
|
518
521
|
|
|
519
|
-
const
|
|
522
|
+
const updateActivityTimestamp = (indicator, state) => {
|
|
523
|
+
if (!indicator || !state) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const rawTimestamp = Number(indicator.dataset?.timestamp);
|
|
527
|
+
if (Number.isFinite(rawTimestamp)) {
|
|
528
|
+
state.lastActivityTimestamp = rawTimestamp;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const handleIndicatorChange = (indicator, changedAttribute = 'class') => {
|
|
520
533
|
const link = indicator.closest(FRAME_LINK_SELECTOR);
|
|
521
534
|
if (!link) {
|
|
522
535
|
return;
|
|
@@ -529,12 +542,21 @@ const ensureTabAccessories = aggregateDebounce(() => {
|
|
|
529
542
|
if (!state) {
|
|
530
543
|
return;
|
|
531
544
|
}
|
|
545
|
+
if (changedAttribute === 'data-timestamp') {
|
|
546
|
+
updateActivityTimestamp(indicator, state);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
532
550
|
const isLive = indicator.classList.contains(LIVE_CLASS);
|
|
533
551
|
state.isLive = isLive;
|
|
552
|
+
updateActivityTimestamp(indicator, state);
|
|
534
553
|
log('Indicator change', { frameName, isLive, awaitingLive: state.awaitingLive, awaitingIdle: state.awaitingIdle });
|
|
535
554
|
|
|
536
555
|
if (isLive) {
|
|
537
556
|
state.lastLiveTimestamp = Date.now();
|
|
557
|
+
if (!state.commandStartTimestamp) {
|
|
558
|
+
state.commandStartTimestamp = state.lastActivityTimestamp || state.lastLiveTimestamp;
|
|
559
|
+
}
|
|
538
560
|
if (state.awaitingLive && state.hasRecentInput) {
|
|
539
561
|
state.awaitingLive = false;
|
|
540
562
|
state.awaitingIdle = true;
|
|
@@ -543,24 +565,41 @@ const ensureTabAccessories = aggregateDebounce(() => {
|
|
|
543
565
|
}
|
|
544
566
|
|
|
545
567
|
if (state.awaitingIdle && state.hasRecentInput && !state.notified) {
|
|
546
|
-
|
|
568
|
+
const activityTs = Number.isFinite(state.lastActivityTimestamp)
|
|
569
|
+
? state.lastActivityTimestamp
|
|
570
|
+
: Number(indicator.dataset?.timestamp);
|
|
571
|
+
const startTs = Number.isFinite(state.commandStartTimestamp)
|
|
572
|
+
? state.commandStartTimestamp
|
|
573
|
+
: null;
|
|
574
|
+
|
|
575
|
+
let runtimeMs = null;
|
|
576
|
+
if (Number.isFinite(activityTs) && Number.isFinite(startTs) && activityTs >= startTs) {
|
|
577
|
+
runtimeMs = activityTs - startTs;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (runtimeMs !== null && runtimeMs < MIN_COMMAND_DURATION_MS) {
|
|
581
|
+
log('Skipping idle notification (command completed quickly)', { frameName, runtimeMs });
|
|
582
|
+
} else if (!state.notifyEnabled) {
|
|
547
583
|
log('Notifications disabled for frame', frameName);
|
|
548
584
|
} else if (shouldNotify(link)) {
|
|
549
585
|
sendNotification(link, state);
|
|
550
586
|
state.notified = true;
|
|
551
|
-
log('Idle notification dispatched', frameName);
|
|
587
|
+
log('Idle notification dispatched', { frameName, runtimeMs });
|
|
552
588
|
}
|
|
553
589
|
}
|
|
554
590
|
|
|
555
591
|
state.hasRecentInput = false;
|
|
556
592
|
state.awaitingIdle = false;
|
|
557
593
|
state.awaitingLive = false;
|
|
594
|
+
state.commandStartTimestamp = 0;
|
|
595
|
+
state.lastLiveTimestamp = 0;
|
|
596
|
+
state.lastActivityTimestamp = 0;
|
|
558
597
|
};
|
|
559
598
|
|
|
560
599
|
const indicatorObserver = new MutationObserver((mutations) => {
|
|
561
600
|
for (const mutation of mutations) {
|
|
562
601
|
if (mutation.type === 'attributes' && mutation.target instanceof HTMLElement) {
|
|
563
|
-
handleIndicatorChange(mutation.target);
|
|
602
|
+
handleIndicatorChange(mutation.target, mutation.attributeName || 'class');
|
|
564
603
|
}
|
|
565
604
|
}
|
|
566
605
|
});
|
|
@@ -573,7 +612,7 @@ const ensureTabAccessories = aggregateDebounce(() => {
|
|
|
573
612
|
continue;
|
|
574
613
|
}
|
|
575
614
|
if (node.matches(TAB_UPDATED_SELECTOR)) {
|
|
576
|
-
indicatorObserver.observe(node, { attributes: true, attributeFilter: ['class'] });
|
|
615
|
+
indicatorObserver.observe(node, { attributes: true, attributeFilter: ['class', 'data-timestamp'] });
|
|
577
616
|
observedIndicators.add(node);
|
|
578
617
|
log('Observed newly added indicator', node);
|
|
579
618
|
shouldRescan = true;
|
|
@@ -615,6 +654,9 @@ const ensureTabAccessories = aggregateDebounce(() => {
|
|
|
615
654
|
state.awaitingIdle = false;
|
|
616
655
|
state.notified = false;
|
|
617
656
|
state.lastInput = sanitisePreview(data.line || '');
|
|
657
|
+
state.commandStartTimestamp = Date.now();
|
|
658
|
+
state.lastActivityTimestamp = 0;
|
|
659
|
+
state.lastLiveTimestamp = 0;
|
|
618
660
|
log('Terminal input captured', { frameName, line: data.line, state: { ...state } });
|
|
619
661
|
|
|
620
662
|
const indicator = findIndicatorForFrame(frameName);
|
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
if (!isLast) {
|
|
36
36
|
const line = this.buffer + segment
|
|
37
37
|
this.buffer = ""
|
|
38
|
-
this.submit(line)
|
|
38
|
+
this.submit(line, { hadLineBreak: true })
|
|
39
39
|
} else {
|
|
40
40
|
this.buffer += segment
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
submit(line) {
|
|
45
|
+
submit(line, meta = {}) {
|
|
46
46
|
const win = this.getWindow()
|
|
47
47
|
if (!win || !win.parent || typeof win.parent.postMessage !== "function") {
|
|
48
48
|
return
|
|
@@ -50,11 +50,13 @@
|
|
|
50
50
|
const safeLine = (line || "").replace(/[\x00-\x1F\x7F]/g, "")
|
|
51
51
|
const preview = safeLine.trim()
|
|
52
52
|
const truncated = preview.length > this.limit ? `${preview.slice(0, this.limit)}...` : preview
|
|
53
|
+
const hadLineBreak = Boolean(meta && meta.hadLineBreak)
|
|
54
|
+
const meaningful = truncated.length > 0 || hadLineBreak
|
|
53
55
|
win.parent.postMessage({
|
|
54
56
|
type: "terminal-input",
|
|
55
57
|
frame: this.getFrameName(),
|
|
56
58
|
line: truncated,
|
|
57
|
-
hasContent:
|
|
59
|
+
hasContent: meaningful
|
|
58
60
|
}, "*")
|
|
59
61
|
}
|
|
60
62
|
}
|
|
@@ -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 = () => {
|