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
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const joinPosix = (base, segment) => {
|
|
3
|
+
if (!base) return segment || '';
|
|
4
|
+
if (!segment) return base;
|
|
5
|
+
return `${base.replace(/\/$/, '')}/${segment}`;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const cssEscape = (value) => {
|
|
9
|
+
if (window.CSS && typeof window.CSS.escape === 'function') {
|
|
10
|
+
return window.CSS.escape(value);
|
|
11
|
+
}
|
|
12
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const createElement = (tag, className) => {
|
|
16
|
+
const el = document.createElement(tag);
|
|
17
|
+
if (className) {
|
|
18
|
+
el.className = className;
|
|
19
|
+
}
|
|
20
|
+
return el;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const isSessionClean = (session) => {
|
|
24
|
+
try {
|
|
25
|
+
return session.getUndoManager().isClean();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const FilesApp = {
|
|
32
|
+
init(config) {
|
|
33
|
+
if (this._initialized) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this._initialized = true;
|
|
37
|
+
|
|
38
|
+
this.state = {
|
|
39
|
+
workspace: config.workspace,
|
|
40
|
+
workspaceLabel: config.workspaceLabel,
|
|
41
|
+
theme: config.theme || 'light',
|
|
42
|
+
initialPath: config.initialPath || '',
|
|
43
|
+
initialPathType: config.initialPathType || null,
|
|
44
|
+
workspaceRoot: config.workspaceRoot || '',
|
|
45
|
+
treeElements: new Map(),
|
|
46
|
+
sessions: new Map(),
|
|
47
|
+
openOrder: [],
|
|
48
|
+
activePath: null,
|
|
49
|
+
selectedTreePath: null,
|
|
50
|
+
statusTimer: null,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
this.dom = {
|
|
54
|
+
treeRoot: document.getElementById('files-app-tree'),
|
|
55
|
+
tabs: document.getElementById('files-app-tabs'),
|
|
56
|
+
editorContainer: document.getElementById('files-app-editor'),
|
|
57
|
+
status: document.getElementById('files-app-status'),
|
|
58
|
+
saveBtn: document.getElementById('files-app-save'),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
this.api = createApi(config.workspace, this.state.workspaceRoot);
|
|
62
|
+
this.ace = setupEditor(this.dom.editorContainer, config.theme);
|
|
63
|
+
this.modelist = ace.require('ace/ext/modelist');
|
|
64
|
+
this.undoManagerCtor = ace.require('ace/undomanager').UndoManager;
|
|
65
|
+
|
|
66
|
+
this.dom.saveBtn.addEventListener('click', (event) => {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
this.saveActiveFile();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
renderTreeRoot.call(this);
|
|
72
|
+
loadDirectory.call(this, '', this.state.treeElements.get(''));
|
|
73
|
+
|
|
74
|
+
if (this.state.initialPath) {
|
|
75
|
+
expandInitialPath.call(this, this.state.initialPath, this.state.initialPathType);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
window.addEventListener('beforeunload', (event) => {
|
|
80
|
+
if (this.hasDirtySessions()) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
event.returnValue = '';
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const handleVisibilityRefresh = () => {
|
|
87
|
+
const activePath = this.state.activePath;
|
|
88
|
+
if (activePath) {
|
|
89
|
+
refreshSessionIfStale.call(this, activePath);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
window.addEventListener('focus', handleVisibilityRefresh);
|
|
93
|
+
document.addEventListener('visibilitychange', () => {
|
|
94
|
+
if (!document.hidden) {
|
|
95
|
+
handleVisibilityRefresh();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
setStatus.call(this, 'Ready');
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
hasDirtySessions() {
|
|
103
|
+
for (const { session } of this.state.sessions.values()) {
|
|
104
|
+
if (!isSessionClean(session)) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async openFile(path, displayName) {
|
|
112
|
+
const existing = this.state.sessions.get(path);
|
|
113
|
+
if (existing) {
|
|
114
|
+
setActiveSession.call(this, path);
|
|
115
|
+
setTreeSelection.call(this, path);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
setStatus.call(this, `Opening ${displayName}…`);
|
|
121
|
+
const payload = await this.api.read(path);
|
|
122
|
+
const session = ace.createEditSession(payload.content || '', undefined);
|
|
123
|
+
session.setUseWrapMode(true);
|
|
124
|
+
session.setOptions({
|
|
125
|
+
tabSize: 2,
|
|
126
|
+
useSoftTabs: true,
|
|
127
|
+
newLineMode: 'unix',
|
|
128
|
+
});
|
|
129
|
+
const mode = this.modelist.getModeForPath(displayName).mode || 'ace/mode/text';
|
|
130
|
+
session.setMode(mode);
|
|
131
|
+
session.setUndoManager(new this.undoManagerCtor());
|
|
132
|
+
session.getUndoManager().markClean();
|
|
133
|
+
|
|
134
|
+
session.on('change', () => {
|
|
135
|
+
const entryRef = this.state.sessions.get(path);
|
|
136
|
+
if (entryRef && entryRef.suppressChange && entryRef.suppressChange > 0) {
|
|
137
|
+
entryRef.suppressChange = Math.max(0, entryRef.suppressChange - 1);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
updateDirtyState.call(this, path);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const tabEl = createTab.call(this, path, displayName);
|
|
144
|
+
this.state.sessions.set(path, {
|
|
145
|
+
session,
|
|
146
|
+
tabEl,
|
|
147
|
+
name: displayName,
|
|
148
|
+
mode,
|
|
149
|
+
mtime: payload.mtime,
|
|
150
|
+
size: payload.size,
|
|
151
|
+
stale: false,
|
|
152
|
+
suppressChange: 0,
|
|
153
|
+
lastPromptMtime: null,
|
|
154
|
+
});
|
|
155
|
+
this.state.openOrder.push(path);
|
|
156
|
+
|
|
157
|
+
setActiveSession.call(this, path);
|
|
158
|
+
setTreeSelection.call(this, path);
|
|
159
|
+
setStatus.call(this, `Opened ${displayName}`);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error(error);
|
|
162
|
+
setStatus.call(this, error.message || 'Failed to open file', 'error');
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async saveActiveFile() {
|
|
167
|
+
const activePath = this.state.activePath;
|
|
168
|
+
if (!activePath) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const entry = this.state.sessions.get(activePath);
|
|
172
|
+
if (!entry) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const content = entry.session.getValue();
|
|
176
|
+
this.dom.saveBtn.disabled = true;
|
|
177
|
+
setStatus.call(this, 'Saving…');
|
|
178
|
+
try {
|
|
179
|
+
const saveResult = await this.api.save(activePath, content);
|
|
180
|
+
entry.session.getUndoManager().markClean();
|
|
181
|
+
entry.mtime = saveResult?.mtime ?? entry.mtime;
|
|
182
|
+
entry.size = saveResult?.size ?? content.length;
|
|
183
|
+
markTabStale.call(this, activePath, false);
|
|
184
|
+
updateDirtyState.call(this, activePath);
|
|
185
|
+
setStatus.call(this, `Saved ${entry.name}`, 'success');
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error(error);
|
|
188
|
+
setStatus.call(this, error.message || 'Failed to save file', 'error');
|
|
189
|
+
} finally {
|
|
190
|
+
updateSaveState.call(this);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
closeFile(path, { force = false } = {}) {
|
|
195
|
+
const info = this.state.sessions.get(path);
|
|
196
|
+
if (!info) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
markTabStale.call(this, path, false);
|
|
200
|
+
if (!force && !isSessionClean(info.session)) {
|
|
201
|
+
const confirmClose = window.confirm('Discard unsaved changes?');
|
|
202
|
+
if (!confirmClose) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (typeof info.session.destroy === 'function') {
|
|
207
|
+
info.session.destroy();
|
|
208
|
+
}
|
|
209
|
+
if (info.tabEl && info.tabEl.parentNode) {
|
|
210
|
+
info.tabEl.parentNode.removeChild(info.tabEl);
|
|
211
|
+
}
|
|
212
|
+
this.state.sessions.delete(path);
|
|
213
|
+
this.state.openOrder = this.state.openOrder.filter((entryPath) => entryPath !== path);
|
|
214
|
+
|
|
215
|
+
if (this.state.activePath === path) {
|
|
216
|
+
const nextPath = this.state.openOrder[this.state.openOrder.length - 1];
|
|
217
|
+
if (nextPath) {
|
|
218
|
+
setActiveSession.call(this, nextPath);
|
|
219
|
+
} else {
|
|
220
|
+
this.state.activePath = null;
|
|
221
|
+
this.ace.setSession(ace.createEditSession('', 'ace/mode/text'));
|
|
222
|
+
this.ace.setReadOnly(true);
|
|
223
|
+
updateSaveState.call(this);
|
|
224
|
+
setTreeSelection.call(this, null);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
function createApi(workspace, workspaceRoot) {
|
|
231
|
+
const list = async (pathPosix) => {
|
|
232
|
+
const params = new URLSearchParams({ workspace });
|
|
233
|
+
if (workspaceRoot) {
|
|
234
|
+
params.set('root', workspaceRoot);
|
|
235
|
+
}
|
|
236
|
+
if (pathPosix) {
|
|
237
|
+
params.set('path', pathPosix);
|
|
238
|
+
}
|
|
239
|
+
const response = await fetch(`/api/files/list?${params.toString()}`);
|
|
240
|
+
return parseJsonResponse(response);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const read = async (pathPosix) => {
|
|
244
|
+
const params = new URLSearchParams({ workspace });
|
|
245
|
+
if (workspaceRoot) {
|
|
246
|
+
params.set('root', workspaceRoot);
|
|
247
|
+
}
|
|
248
|
+
if (pathPosix) {
|
|
249
|
+
params.set('path', pathPosix);
|
|
250
|
+
}
|
|
251
|
+
const response = await fetch(`/api/files/read?${params.toString()}`);
|
|
252
|
+
return parseJsonResponse(response);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const save = async (pathPosix, content) => {
|
|
256
|
+
const response = await fetch('/api/files/save', {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: {
|
|
259
|
+
'Content-Type': 'application/json',
|
|
260
|
+
},
|
|
261
|
+
body: JSON.stringify({ workspace, path: pathPosix, content, root: workspaceRoot }),
|
|
262
|
+
});
|
|
263
|
+
return parseJsonResponse(response);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const stat = async (pathPosix) => {
|
|
267
|
+
const params = new URLSearchParams({ workspace });
|
|
268
|
+
if (workspaceRoot) {
|
|
269
|
+
params.set('root', workspaceRoot);
|
|
270
|
+
}
|
|
271
|
+
if (pathPosix) {
|
|
272
|
+
params.set('path', pathPosix);
|
|
273
|
+
}
|
|
274
|
+
params.set('meta', '1');
|
|
275
|
+
const response = await fetch(`/api/files/read?${params.toString()}`);
|
|
276
|
+
return parseJsonResponse(response);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return { list, read, save, stat };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function parseJsonResponse(response) {
|
|
283
|
+
if (!response.ok) {
|
|
284
|
+
let message;
|
|
285
|
+
try {
|
|
286
|
+
const payload = await response.json();
|
|
287
|
+
message = payload && payload.error;
|
|
288
|
+
} catch (err) {
|
|
289
|
+
message = await response.text();
|
|
290
|
+
}
|
|
291
|
+
throw new Error(message || `Request failed (${response.status})`);
|
|
292
|
+
}
|
|
293
|
+
return response.json();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function setupEditor(container, theme) {
|
|
297
|
+
const editor = ace.edit(container);
|
|
298
|
+
const resolvedTheme = theme === 'dark' ? 'ace/theme/idle_fingers' : 'ace/theme/tomorrow';
|
|
299
|
+
editor.setTheme(resolvedTheme);
|
|
300
|
+
editor.setShowPrintMargin(false);
|
|
301
|
+
editor.renderer.setScrollMargin(8, 16, 0, 0);
|
|
302
|
+
editor.setOptions({
|
|
303
|
+
fontSize: 13,
|
|
304
|
+
wrap: true,
|
|
305
|
+
highlightActiveLine: true,
|
|
306
|
+
showFoldWidgets: true,
|
|
307
|
+
});
|
|
308
|
+
editor.setReadOnly(true);
|
|
309
|
+
return editor;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function renderTreeRoot() {
|
|
313
|
+
this.dom.treeRoot.innerHTML = '';
|
|
314
|
+
const list = createElement('ul', 'files-app__tree');
|
|
315
|
+
this.dom.treeRoot.appendChild(list);
|
|
316
|
+
const rootItem = createTreeItem.call(this, {
|
|
317
|
+
name: this.state.workspaceLabel,
|
|
318
|
+
path: '',
|
|
319
|
+
type: 'directory',
|
|
320
|
+
isRoot: true,
|
|
321
|
+
});
|
|
322
|
+
rootItem.dataset.loaded = 'false';
|
|
323
|
+
rootItem.dataset.expanded = 'false';
|
|
324
|
+
list.appendChild(rootItem);
|
|
325
|
+
this.state.treeElements.set('', rootItem);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function createTreeItem(entry) {
|
|
329
|
+
const li = createElement('li', 'files-app__tree-item');
|
|
330
|
+
li.dataset.type = entry.type;
|
|
331
|
+
li.dataset.path = entry.path;
|
|
332
|
+
li.dataset.expanded = entry.isRoot ? 'true' : 'false';
|
|
333
|
+
|
|
334
|
+
const row = createElement('button', 'files-app__tree-row');
|
|
335
|
+
row.type = 'button';
|
|
336
|
+
row.dataset.path = entry.path;
|
|
337
|
+
row.dataset.type = entry.type;
|
|
338
|
+
|
|
339
|
+
const icon = createElement('i');
|
|
340
|
+
icon.className = entry.type === 'directory' ? 'fa-regular fa-folder' : 'fa-regular fa-file-lines';
|
|
341
|
+
row.appendChild(icon);
|
|
342
|
+
|
|
343
|
+
const label = createElement('span');
|
|
344
|
+
label.textContent = entry.name;
|
|
345
|
+
row.appendChild(label);
|
|
346
|
+
|
|
347
|
+
if (entry.type === 'directory') {
|
|
348
|
+
row.addEventListener('click', (event) => {
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
toggleDirectory.call(this, li, entry.path);
|
|
351
|
+
});
|
|
352
|
+
} else {
|
|
353
|
+
row.addEventListener('click', (event) => {
|
|
354
|
+
event.preventDefault();
|
|
355
|
+
this.openFile(entry.path, entry.name);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
li.appendChild(row);
|
|
360
|
+
const children = createElement('ul', 'files-app__tree-children');
|
|
361
|
+
li.appendChild(children);
|
|
362
|
+
return li;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function loadDirectory(relativePath, treeItem) {
|
|
366
|
+
if (!treeItem || treeItem.dataset.loading === 'true') {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
treeItem.dataset.loading = 'true';
|
|
370
|
+
try {
|
|
371
|
+
const payload = await this.api.list(relativePath);
|
|
372
|
+
renderDirectoryChildren.call(this, relativePath, treeItem, payload.entries || []);
|
|
373
|
+
treeItem.dataset.loaded = 'true';
|
|
374
|
+
treeItem.dataset.expanded = 'true';
|
|
375
|
+
const childrenContainer = treeItem.querySelector('.files-app__tree-children');
|
|
376
|
+
if (childrenContainer) {
|
|
377
|
+
childrenContainer.style.display = 'block';
|
|
378
|
+
}
|
|
379
|
+
updateDirectoryIcon(treeItem);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
console.error(error);
|
|
382
|
+
setStatus.call(this, error.message || 'Unable to load directory', 'error');
|
|
383
|
+
} finally {
|
|
384
|
+
treeItem.dataset.loading = 'false';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function renderDirectoryChildren(parentPath, parentItem, entries) {
|
|
389
|
+
const prefix = parentPath ? `${parentPath}/` : '';
|
|
390
|
+
for (const key of Array.from(this.state.treeElements.keys())) {
|
|
391
|
+
if (!key) continue;
|
|
392
|
+
if (key === parentPath) continue;
|
|
393
|
+
if (key.startsWith(prefix)) {
|
|
394
|
+
this.state.treeElements.delete(key);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const container = parentItem.querySelector('.files-app__tree-children');
|
|
399
|
+
container.innerHTML = '';
|
|
400
|
+
|
|
401
|
+
if (!entries.length) {
|
|
402
|
+
const empty = createElement('div', 'files-app__empty-state');
|
|
403
|
+
empty.textContent = 'No files yet';
|
|
404
|
+
container.appendChild(empty);
|
|
405
|
+
parentItem.dataset.expanded = 'true';
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const fragment = document.createDocumentFragment();
|
|
410
|
+
for (const entry of entries) {
|
|
411
|
+
const item = createTreeItem.call(this, entry);
|
|
412
|
+
item.dataset.loaded = entry.type === 'directory' && entry.hasChildren === false ? 'true' : 'false';
|
|
413
|
+
item.dataset.expanded = 'false';
|
|
414
|
+
fragment.appendChild(item);
|
|
415
|
+
this.state.treeElements.set(entry.path, item);
|
|
416
|
+
}
|
|
417
|
+
container.appendChild(fragment);
|
|
418
|
+
container.style.display = parentItem.dataset.expanded === 'true' ? 'block' : 'none';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function toggleDirectory(treeItem, relativePath) {
|
|
422
|
+
if (!treeItem || treeItem.dataset.loading === 'true') {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (treeItem.dataset.loaded !== 'true') {
|
|
426
|
+
await loadDirectory.call(this, relativePath, treeItem);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const expanded = treeItem.dataset.expanded === 'true';
|
|
430
|
+
const nextExpanded = expanded ? 'false' : 'true';
|
|
431
|
+
treeItem.dataset.expanded = nextExpanded;
|
|
432
|
+
const childrenContainer = treeItem.querySelector('.files-app__tree-children');
|
|
433
|
+
if (childrenContainer) {
|
|
434
|
+
if (nextExpanded === 'true') {
|
|
435
|
+
childrenContainer.style.display = 'block';
|
|
436
|
+
if (childrenContainer.childElementCount === 0) {
|
|
437
|
+
await loadDirectory.call(this, relativePath, treeItem);
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
childrenContainer.style.display = 'none';
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
updateDirectoryIcon(treeItem);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function updateDirectoryIcon(treeItem) {
|
|
447
|
+
const row = treeItem.querySelector('.files-app__tree-row i');
|
|
448
|
+
if (!row) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const expanded = treeItem.dataset.expanded === 'true';
|
|
452
|
+
row.className = expanded ? 'fa-regular fa-folder-open' : 'fa-regular fa-folder';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function createTab(path, label) {
|
|
456
|
+
const tab = createElement('button', 'files-app__tab');
|
|
457
|
+
tab.type = 'button';
|
|
458
|
+
tab.dataset.path = path;
|
|
459
|
+
tab.setAttribute('role', 'tab');
|
|
460
|
+
const text = createElement('span', 'files-app__tab-label');
|
|
461
|
+
text.textContent = label;
|
|
462
|
+
tab.appendChild(text);
|
|
463
|
+
|
|
464
|
+
const close = createElement('button', 'files-app__tab-close');
|
|
465
|
+
close.type = 'button';
|
|
466
|
+
close.innerHTML = '×';
|
|
467
|
+
close.addEventListener('click', (event) => {
|
|
468
|
+
event.stopPropagation();
|
|
469
|
+
this.closeFile(path);
|
|
470
|
+
});
|
|
471
|
+
tab.appendChild(close);
|
|
472
|
+
|
|
473
|
+
tab.addEventListener('click', () => {
|
|
474
|
+
setActiveSession.call(this, path);
|
|
475
|
+
setTreeSelection.call(this, path);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
this.dom.tabs.appendChild(tab);
|
|
479
|
+
return tab;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function markTabStale(path, isStale) {
|
|
483
|
+
const entry = this.state.sessions.get(path);
|
|
484
|
+
if (!entry) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
entry.stale = Boolean(isStale);
|
|
488
|
+
if (entry.tabEl) {
|
|
489
|
+
entry.tabEl.classList.toggle('files-app__tab--stale', entry.stale);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function refreshSessionIfStale(path) {
|
|
494
|
+
const entry = this.state.sessions.get(path);
|
|
495
|
+
if (!entry || !this.api || typeof this.api.stat !== 'function') {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const meta = await this.api.stat(path);
|
|
500
|
+
if (!meta || typeof meta.mtime !== 'number') {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const storedMtime = typeof entry.mtime === 'number' ? entry.mtime : null;
|
|
504
|
+
const storedSize = typeof entry.size === 'number' ? entry.size : null;
|
|
505
|
+
const changed = (storedMtime !== null && storedMtime !== meta.mtime) ||
|
|
506
|
+
(storedSize !== null && storedSize !== meta.size);
|
|
507
|
+
if (!changed) {
|
|
508
|
+
if (entry.stale) {
|
|
509
|
+
markTabStale.call(this, path, false);
|
|
510
|
+
}
|
|
511
|
+
entry.lastPromptMtime = null;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (entry.lastPromptMtime === meta.mtime) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
entry.lastPromptMtime = meta.mtime;
|
|
518
|
+
if (!isSessionClean(entry.session)) {
|
|
519
|
+
markTabStale.call(this, path, true);
|
|
520
|
+
if (!entry.stale) {
|
|
521
|
+
setStatus.call(this, `${entry.name} changed on disk`, 'error');
|
|
522
|
+
}
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const shouldReload = window.confirm(`${entry.name} changed on disk. Reload from disk?`);
|
|
526
|
+
if (!shouldReload) {
|
|
527
|
+
markTabStale.call(this, path, true);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const payload = await this.api.read(path);
|
|
531
|
+
entry.suppressChange = 3;
|
|
532
|
+
entry.session.setValue(payload.content || '', -1);
|
|
533
|
+
entry.session.clearSelection();
|
|
534
|
+
entry.session.getUndoManager().markClean();
|
|
535
|
+
entry.mtime = payload.mtime;
|
|
536
|
+
entry.size = payload.size;
|
|
537
|
+
entry.lastPromptMtime = null;
|
|
538
|
+
markTabStale.call(this, path, false);
|
|
539
|
+
updateDirtyState.call(this, path);
|
|
540
|
+
setStatus.call(this, `${entry.name} reloaded`, 'success');
|
|
541
|
+
} catch (error) {
|
|
542
|
+
console.error(error);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function setActiveSession(path) {
|
|
547
|
+
const entry = this.state.sessions.get(path);
|
|
548
|
+
if (!entry) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
this.state.activePath = path;
|
|
552
|
+
this.ace.setReadOnly(false);
|
|
553
|
+
this.ace.setSession(entry.session);
|
|
554
|
+
this.ace.focus();
|
|
555
|
+
updateTabsUi.call(this);
|
|
556
|
+
updateDirtyState.call(this, path);
|
|
557
|
+
setTreeSelection.call(this, path);
|
|
558
|
+
refreshSessionIfStale.call(this, path);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function updateTabsUi() {
|
|
562
|
+
const activePath = this.state.activePath;
|
|
563
|
+
for (const [path, info] of this.state.sessions.entries()) {
|
|
564
|
+
if (!info.tabEl) continue;
|
|
565
|
+
if (path === activePath) {
|
|
566
|
+
info.tabEl.classList.add('files-app__tab--active');
|
|
567
|
+
info.tabEl.setAttribute('aria-selected', 'true');
|
|
568
|
+
} else {
|
|
569
|
+
info.tabEl.classList.remove('files-app__tab--active');
|
|
570
|
+
info.tabEl.setAttribute('aria-selected', 'false');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function updateDirtyState(path) {
|
|
576
|
+
const entry = this.state.sessions.get(path);
|
|
577
|
+
if (!entry) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const dirty = !isSessionClean(entry.session);
|
|
581
|
+
if (entry.tabEl) {
|
|
582
|
+
entry.tabEl.classList.toggle('files-app__tab--dirty', dirty);
|
|
583
|
+
}
|
|
584
|
+
if (path === this.state.activePath) {
|
|
585
|
+
updateSaveState.call(this);
|
|
586
|
+
if (dirty) {
|
|
587
|
+
setStatus.call(this, 'Unsaved changes');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function updateSaveState() {
|
|
593
|
+
const activePath = this.state.activePath;
|
|
594
|
+
if (!activePath) {
|
|
595
|
+
this.dom.saveBtn.disabled = true;
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const entry = this.state.sessions.get(activePath);
|
|
599
|
+
if (!entry) {
|
|
600
|
+
this.dom.saveBtn.disabled = true;
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
this.dom.saveBtn.disabled = isSessionClean(entry.session);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function setTreeSelection(path) {
|
|
607
|
+
if (this.state.selectedTreePath && this.state.treeElements.has(this.state.selectedTreePath)) {
|
|
608
|
+
const prevItem = this.state.treeElements.get(this.state.selectedTreePath);
|
|
609
|
+
const prevRow = prevItem && prevItem.querySelector('.files-app__tree-row');
|
|
610
|
+
if (prevRow) {
|
|
611
|
+
prevRow.dataset.selected = 'false';
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!path) {
|
|
616
|
+
this.state.selectedTreePath = null;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const item = this.state.treeElements.get(path);
|
|
621
|
+
if (!item) {
|
|
622
|
+
this.state.selectedTreePath = null;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const row = item.querySelector('.files-app__tree-row');
|
|
626
|
+
if (row) {
|
|
627
|
+
row.dataset.selected = 'true';
|
|
628
|
+
}
|
|
629
|
+
this.state.selectedTreePath = path;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function setStatus(message, type = 'info') {
|
|
633
|
+
if (this.state.statusTimer) {
|
|
634
|
+
clearTimeout(this.state.statusTimer);
|
|
635
|
+
this.state.statusTimer = null;
|
|
636
|
+
}
|
|
637
|
+
this.dom.status.dataset.state = type;
|
|
638
|
+
this.dom.status.textContent = message;
|
|
639
|
+
if (type === 'success') {
|
|
640
|
+
this.state.statusTimer = setTimeout(() => {
|
|
641
|
+
this.dom.status.dataset.state = 'info';
|
|
642
|
+
this.dom.status.textContent = 'Ready';
|
|
643
|
+
this.state.statusTimer = null;
|
|
644
|
+
}, 3000);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function expandInitialPath(initialPath, initialType) {
|
|
649
|
+
const segments = String(initialPath).split('/').filter(Boolean);
|
|
650
|
+
if (segments.length === 0) {
|
|
651
|
+
const root = this.state.treeElements.get('');
|
|
652
|
+
if (root) {
|
|
653
|
+
await loadDirectory.call(this, '', root);
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let currentPath = '';
|
|
659
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
660
|
+
const segment = segments[i];
|
|
661
|
+
const nextPath = joinPosix(currentPath, segment);
|
|
662
|
+
const parentItem = this.state.treeElements.get(currentPath);
|
|
663
|
+
if (parentItem && parentItem.dataset.loaded !== 'true') {
|
|
664
|
+
await loadDirectory.call(this, currentPath, parentItem);
|
|
665
|
+
}
|
|
666
|
+
const targetItem = this.state.treeElements.get(nextPath);
|
|
667
|
+
if (!targetItem) {
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
if (i < segments.length - 1 || initialType === 'directory') {
|
|
671
|
+
if (targetItem.dataset.loaded !== 'true') {
|
|
672
|
+
await loadDirectory.call(this, nextPath, targetItem);
|
|
673
|
+
}
|
|
674
|
+
targetItem.dataset.expanded = 'true';
|
|
675
|
+
updateDirectoryIcon(targetItem);
|
|
676
|
+
}
|
|
677
|
+
currentPath = nextPath;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (initialType === 'file' && currentPath) {
|
|
681
|
+
this.openFile(currentPath, segments[segments.length - 1]);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
window.FilesApp = FilesApp;
|
|
686
|
+
})();
|