typescript-virtual-container 1.6.1 → 1.6.3

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.
@@ -0,0 +1,896 @@
1
+ import { WebTermRenderer } from "./webTermRenderer";
2
+ import { keyToBytes } from "../utils/keyToBytes";
3
+ function toChunk(bytes) {
4
+ const g = globalThis;
5
+ return g.Buffer?.from(bytes) ?? bytes;
6
+ }
7
+ // ── Desktop Manager ───────────────────────────────────────────────────
8
+ export class DesktopManager {
9
+ shell;
10
+ container;
11
+ active = false;
12
+ windows = [];
13
+ zCounter = 100;
14
+ menuOpen = false;
15
+ nextWinId = 0;
16
+ clockInterval;
17
+ onExit = null;
18
+ stopResolve = null;
19
+ dragState = null;
20
+ _renderGuard = false;
21
+ trashPath = "/root/.local/share/Trash/files";
22
+ docListeners = [];
23
+ pendingTimeouts = new Set();
24
+ constructor(shell, container) {
25
+ this.shell = shell;
26
+ this.container = container;
27
+ this.setupEventDelegation();
28
+ }
29
+ isActive() { return this.active; }
30
+ setOnExit(cb) { this.onExit = cb; }
31
+ start() {
32
+ if (this.active)
33
+ return Promise.resolve();
34
+ this.active = true;
35
+ this.container.style.display = "block";
36
+ this.renderAll();
37
+ this.clockInterval = setInterval(() => this.updateClock(), 30_000);
38
+ return new Promise((resolve) => {
39
+ this.stopResolve = resolve;
40
+ });
41
+ }
42
+ stop() {
43
+ if (!this.active)
44
+ return;
45
+ this.active = false;
46
+ this.container.style.display = "none";
47
+ if (this.clockInterval)
48
+ clearInterval(this.clockInterval);
49
+ this.clockInterval = undefined;
50
+ this.windows = [];
51
+ this.menuOpen = false;
52
+ this.dragState = null;
53
+ for (const id of this.pendingTimeouts)
54
+ clearTimeout(id);
55
+ this.pendingTimeouts.clear();
56
+ this.removeAllDocListeners();
57
+ this.stopResolve?.();
58
+ this.stopResolve = null;
59
+ this.onExit?.();
60
+ }
61
+ getFocusedTerminal() {
62
+ for (const w of this.windows) {
63
+ if (w.content.type === "terminal" && w.focused && !w.minimized) {
64
+ return {
65
+ stream: w.content.stream,
66
+ dataListeners: w.content.dataListeners,
67
+ preEl: w.content.preEl,
68
+ };
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+ handleKeyDown(e) {
74
+ if (!this.active)
75
+ return;
76
+ if (e.key === "Escape" && this.menuOpen) {
77
+ this.menuOpen = false;
78
+ this.renderPanel();
79
+ return;
80
+ }
81
+ const focusedTerm = this.getFocusedTerminal();
82
+ if (!focusedTerm)
83
+ return;
84
+ if (e.metaKey)
85
+ return;
86
+ if (e.ctrlKey && (e.key === "c" || e.key === "v") && !e.altKey) {
87
+ e.preventDefault();
88
+ }
89
+ else {
90
+ e.preventDefault();
91
+ }
92
+ const bytes = keyToBytes(e);
93
+ if (!bytes)
94
+ return;
95
+ for (const l of focusedTerm.dataListeners)
96
+ l(toChunk(bytes));
97
+ }
98
+ handlePaste(e) {
99
+ const focusedTerm = this.getFocusedTerminal();
100
+ if (!focusedTerm)
101
+ return;
102
+ e.preventDefault();
103
+ const text = e.clipboardData?.getData("text") ?? "";
104
+ if (!text)
105
+ return;
106
+ const enc = new TextEncoder();
107
+ const bytes = enc.encode(text);
108
+ for (const l of focusedTerm.dataListeners)
109
+ l(toChunk(bytes));
110
+ }
111
+ createTerminalWindow() {
112
+ const cols = 80;
113
+ const rows = 24;
114
+ const termRenderer = new WebTermRenderer(rows, cols);
115
+ const dataListeners = [];
116
+ const closeListeners = [];
117
+ const id = this.createWindow({
118
+ title: "Terminal",
119
+ width: 720,
120
+ height: 440,
121
+ content: {
122
+ type: "terminal",
123
+ termRenderer,
124
+ dataListeners,
125
+ stream: null,
126
+ },
127
+ });
128
+ const winId = id; // capture window id for the closure
129
+ const stream = {
130
+ write: (data) => {
131
+ termRenderer.write(data);
132
+ this.renderTerminalContentById(winId);
133
+ },
134
+ exit: () => undefined,
135
+ end: () => { for (const l of closeListeners)
136
+ l(); },
137
+ on: (event, listener) => {
138
+ if (event === "data")
139
+ dataListeners.push(listener);
140
+ else if (event === "close")
141
+ closeListeners.push(listener);
142
+ },
143
+ };
144
+ // Attach stream to the window
145
+ const w = this.windows.find((ww) => ww.id === winId);
146
+ if (w && w.content.type === "terminal") {
147
+ w.content.stream = stream;
148
+ }
149
+ // Start shell asynchronously so the calling command can finish first
150
+ const tid = setTimeout(() => {
151
+ this.pendingTimeouts.delete(tid);
152
+ this.shell.startInteractiveSession(stream, "root", null, "desktop", { cols, rows });
153
+ }, 0);
154
+ this.pendingTimeouts.add(tid);
155
+ return id;
156
+ }
157
+ createThunarWindow(path = "/root") {
158
+ return this.createWindow({
159
+ title: `Thunar: ${path}`,
160
+ width: 600,
161
+ height: 400,
162
+ content: { type: "thunar", path },
163
+ });
164
+ }
165
+ createEditorWindow(path = "/root/untitled.txt") {
166
+ const id = this.createWindow({
167
+ title: `Mousepad — ${path.split("/").pop()}`,
168
+ width: 640,
169
+ height: 480,
170
+ content: { type: "editor", path, dirty: false },
171
+ });
172
+ // Attach save/input listeners via event delegation (handled in setupEventDelegation)
173
+ return id;
174
+ }
175
+ createAboutWindow() {
176
+ return this.createWindow({
177
+ title: "About Fortune GNU/Linux",
178
+ width: 400,
179
+ height: 280,
180
+ content: { type: "about" },
181
+ });
182
+ }
183
+ closeWindow(id) {
184
+ const idx = this.windows.findIndex((w) => w.id === id);
185
+ if (idx === -1)
186
+ return;
187
+ this.windows.splice(idx, 1);
188
+ if (this.windows.length > 0) {
189
+ this.focusWindow(this.windows[this.windows.length - 1].id);
190
+ }
191
+ this.renderAll();
192
+ }
193
+ toggleMinimize(id) {
194
+ const w = this.windows.find((ww) => ww.id === id);
195
+ if (!w)
196
+ return;
197
+ w.minimized = !w.minimized;
198
+ if (!w.minimized)
199
+ this.focusWindow(id);
200
+ else
201
+ this.renderAll();
202
+ }
203
+ focusWindow(id) {
204
+ for (const w of this.windows)
205
+ w.focused = false;
206
+ const w = this.windows.find((ww) => ww.id === id);
207
+ if (w) {
208
+ w.focused = true;
209
+ w.zIndex = ++this.zCounter;
210
+ w.minimized = false;
211
+ }
212
+ this.renderAll();
213
+ }
214
+ // ── Internal ──────────────────────────────────────────────────────
215
+ createWindow(opts) {
216
+ const id = `win-${++this.nextWinId}`;
217
+ const count = this.windows.length;
218
+ const offset = count * 30;
219
+ const win = {
220
+ id,
221
+ title: opts.title,
222
+ x: 60 + offset,
223
+ y: 40 + offset,
224
+ width: opts.width,
225
+ height: opts.height,
226
+ minimized: false,
227
+ focused: true,
228
+ zIndex: ++this.zCounter,
229
+ content: opts.content,
230
+ };
231
+ for (const w of this.windows)
232
+ w.focused = false;
233
+ this.windows.push(win);
234
+ // Create DOM element synchronously (not guarded) so it exists for stream writes
235
+ this.ensureWindowElement(win);
236
+ this.renderWindowElement(win);
237
+ this.renderAll();
238
+ return id;
239
+ }
240
+ ensureWindowElement(win) {
241
+ let el = this.container.querySelector(`.desktop-window[data-win-id="${win.id}"]`);
242
+ if (!el) {
243
+ el = document.createElement("div");
244
+ el.className = "desktop-window";
245
+ el.setAttribute("data-win-id", win.id);
246
+ el.innerHTML = `
247
+ <div class="win-header">
248
+ <span class="win-title">${this.escapeHtml(win.title)}</span>
249
+ <div class="win-controls">
250
+ <button class="win-min">─</button>
251
+ <button class="win-close">✕</button>
252
+ </div>
253
+ </div>
254
+ <div class="win-content"></div>
255
+ `;
256
+ this.container.appendChild(el);
257
+ }
258
+ return el;
259
+ }
260
+ renderWindowElement(win) {
261
+ const el = this.ensureWindowElement(win);
262
+ el.style.left = `${win.x}px`;
263
+ el.style.top = `${win.y}px`;
264
+ el.style.width = `${win.width}px`;
265
+ el.style.height = `${win.height}px`;
266
+ el.style.zIndex = String(win.zIndex);
267
+ el.classList.toggle("win-focused", win.focused);
268
+ if (win.content.type === "terminal") {
269
+ this.renderTerminalContentById(win.id);
270
+ }
271
+ else if (win.content.type === "thunar") {
272
+ this.renderThunarContent(el, win.content);
273
+ }
274
+ else if (win.content.type === "about") {
275
+ this.renderAboutContent(el);
276
+ }
277
+ else if (win.content.type === "editor") {
278
+ this.renderEditorContent(el, win.id, win.content);
279
+ }
280
+ }
281
+ addDocListener(target, type, fn) {
282
+ target.addEventListener(type, fn);
283
+ this.docListeners.push({ target, type, fn });
284
+ }
285
+ removeAllDocListeners() {
286
+ for (const { target, type, fn } of this.docListeners) {
287
+ target.removeEventListener(type, fn);
288
+ }
289
+ this.docListeners = [];
290
+ }
291
+ setupEventDelegation() {
292
+ // Delegate click events
293
+ this.container.addEventListener("click", (e) => {
294
+ const target = e.target;
295
+ if (!this.active)
296
+ return;
297
+ // Close button
298
+ if (target.classList.contains("win-close")) {
299
+ const id = target.closest(".desktop-window")?.getAttribute("data-win-id");
300
+ if (id)
301
+ this.closeWindow(id);
302
+ e.stopPropagation();
303
+ return;
304
+ }
305
+ // Minimize button
306
+ if (target.classList.contains("win-min")) {
307
+ const id = target.closest(".desktop-window")?.getAttribute("data-win-id");
308
+ if (id)
309
+ this.toggleMinimize(id);
310
+ e.stopPropagation();
311
+ return;
312
+ }
313
+ // Window header click → focus
314
+ const header = target.closest(".win-header");
315
+ if (header) {
316
+ const id = header.closest(".desktop-window")?.getAttribute("data-win-id");
317
+ if (id) {
318
+ this.focusWindow(id);
319
+ e.stopPropagation();
320
+ return;
321
+ }
322
+ }
323
+ // Window content click → focus
324
+ const winEl = target.closest(".desktop-window");
325
+ if (winEl) {
326
+ const id = winEl.getAttribute("data-win-id");
327
+ if (id) {
328
+ this.focusWindow(id);
329
+ e.stopPropagation();
330
+ return;
331
+ }
332
+ }
333
+ // Desktop icons
334
+ const icon = target.closest(".desktop-icon");
335
+ if (icon) {
336
+ const action = icon.getAttribute("data-action");
337
+ if (action === "terminal")
338
+ this.createTerminalWindow();
339
+ else if (action === "home")
340
+ this.createThunarWindow("/root");
341
+ else if (action === "editor")
342
+ this.createEditorWindow();
343
+ else if (action === "trash")
344
+ this.createThunarWindow(this.trashPath);
345
+ e.stopPropagation();
346
+ return;
347
+ }
348
+ // Panel menu button
349
+ if (target.classList.contains("xfce-menu-button") || target.closest(".xfce-menu-button")) {
350
+ this.menuOpen = !this.menuOpen;
351
+ this.renderPanel();
352
+ e.stopPropagation();
353
+ return;
354
+ }
355
+ // Menu items
356
+ if (target.classList.contains("menu-item")) {
357
+ const action = target.getAttribute("data-action");
358
+ if (action === "terminal")
359
+ this.createTerminalWindow();
360
+ else if (action === "thunar")
361
+ this.createThunarWindow();
362
+ else if (action === "editor")
363
+ this.createEditorWindow();
364
+ else if (action === "about")
365
+ this.createAboutWindow();
366
+ else if (action === "logout")
367
+ this.stop();
368
+ this.menuOpen = false;
369
+ this.renderPanel();
370
+ return;
371
+ }
372
+ // Click outside menu → close it
373
+ if (this.menuOpen) {
374
+ this.menuOpen = false;
375
+ this.renderPanel();
376
+ }
377
+ });
378
+ // Double-click on Thunar entries
379
+ this.container.addEventListener("dblclick", (e) => {
380
+ const entry = e.target.closest(".thunar-entry");
381
+ if (!entry)
382
+ return;
383
+ const path = entry.getAttribute("data-path");
384
+ const type = entry.getAttribute("data-type");
385
+ if (!path)
386
+ return;
387
+ if (type === "directory") {
388
+ const winEl = entry.closest(".desktop-window");
389
+ const id = winEl?.getAttribute("data-win-id");
390
+ const w = id ? this.windows.find((ww) => ww.id === id) : null;
391
+ if (w && w.content.type === "thunar") {
392
+ w.content.path = path;
393
+ w.title = `Thunar: ${path}`;
394
+ const wEl = this.container.querySelector(`.desktop-window[data-win-id="${w.id}"] .win-content`);
395
+ if (wEl)
396
+ wEl.removeAttribute("data-thunar-path");
397
+ this.renderWindowElement(w);
398
+ }
399
+ }
400
+ else {
401
+ this.createEditorWindow(path);
402
+ }
403
+ e.stopPropagation();
404
+ });
405
+ // Context menu on Thunar entries
406
+ this.container.addEventListener("contextmenu", (e) => {
407
+ const entry = e.target.closest(".thunar-entry");
408
+ if (!entry) {
409
+ this.closeContextMenu();
410
+ return;
411
+ }
412
+ const path = entry.getAttribute("data-path");
413
+ const type = entry.getAttribute("data-type");
414
+ if (!path)
415
+ return;
416
+ e.preventDefault();
417
+ e.stopPropagation();
418
+ const inTrash = path.startsWith(this.trashPath);
419
+ const winEl = entry.closest(".desktop-window");
420
+ const winId = winEl?.getAttribute("data-win-id") ?? null;
421
+ this.showContextMenu(e.clientX, e.clientY, inTrash
422
+ ? [
423
+ { label: "Restore", icon: "fa-solid fa-rotate-left", action: () => this.trashRestore(path, winId) },
424
+ { label: "Delete permanently", icon: "fa-solid fa-circle-xmark", danger: true, action: () => this.trashDelete(path, winId) },
425
+ ]
426
+ : [
427
+ { label: type === "directory" ? "Open folder" : "Open", icon: type === "directory" ? "fa-solid fa-folder-open" : "fa-solid fa-file-pen", action: () => {
428
+ if (type === "directory") {
429
+ const w = winId ? this.windows.find((ww) => ww.id === winId) : null;
430
+ if (w && w.content.type === "thunar") {
431
+ w.content.path = path;
432
+ w.title = `Thunar: ${path}`;
433
+ const wEl = this.container.querySelector(`.desktop-window[data-win-id="${w.id}"] .win-content`);
434
+ if (wEl)
435
+ wEl.removeAttribute("data-thunar-path");
436
+ this.renderWindowElement(w);
437
+ }
438
+ }
439
+ else {
440
+ this.createEditorWindow(path);
441
+ }
442
+ }
443
+ },
444
+ { label: "Rename", icon: "fa-solid fa-pencil", action: () => this.renamePrompt(path, winId) },
445
+ { label: "Move to Trash", icon: "fa-solid fa-trash-can", danger: true, action: () => this.moveToTrash(path, winId) },
446
+ ]);
447
+ });
448
+ // Close context menu on click elsewhere
449
+ this.addDocListener(document, "click", () => this.closeContextMenu());
450
+ // Mouse down for window dragging
451
+ this.container.addEventListener("mousedown", (e) => {
452
+ const header = e.target.closest(".win-header");
453
+ if (!header)
454
+ return;
455
+ const winEl = header.closest(".desktop-window");
456
+ if (!winEl)
457
+ return;
458
+ const id = winEl.getAttribute("data-win-id");
459
+ if (!id)
460
+ return;
461
+ const win = this.windows.find((w) => w.id === id);
462
+ if (!win)
463
+ return;
464
+ this.focusWindow(id);
465
+ this.dragState = {
466
+ win,
467
+ startX: e.clientX,
468
+ startY: e.clientY,
469
+ origX: win.x,
470
+ origY: win.y,
471
+ };
472
+ e.preventDefault();
473
+ });
474
+ // Mouse move for dragging
475
+ this.addDocListener(document, "mousemove", (e) => {
476
+ if (!this.dragState)
477
+ return;
478
+ const me = e;
479
+ const dx = me.clientX - this.dragState.startX;
480
+ const dy = me.clientY - this.dragState.startY;
481
+ this.dragState.win.x = Math.max(0, this.dragState.origX + dx);
482
+ this.dragState.win.y = Math.max(0, this.dragState.origY + dy);
483
+ this.renderWindowPositions();
484
+ });
485
+ // Mouse up to end drag
486
+ this.addDocListener(document, "mouseup", () => {
487
+ this.dragState = null;
488
+ });
489
+ // Paste delegation for terminal windows
490
+ this.container.addEventListener("paste", (e) => {
491
+ this.handlePaste(e);
492
+ });
493
+ // Keyboard input for desktop terminal windows (document-level so focus doesn't matter)
494
+ this.addDocListener(document, "keydown", (e) => {
495
+ if (!this.active)
496
+ return;
497
+ if (e.target?.classList?.contains("editor-textarea"))
498
+ return;
499
+ this.handleKeyDown(e);
500
+ });
501
+ }
502
+ // ── Rendering ──────────────────────────────────────────────────────
503
+ renderAll() {
504
+ if (this._renderGuard)
505
+ return;
506
+ this._renderGuard = true;
507
+ try {
508
+ this.renderPanel();
509
+ this.renderDesktopIcons();
510
+ this.renderWindows();
511
+ }
512
+ finally {
513
+ this._renderGuard = false;
514
+ }
515
+ }
516
+ renderPanel() {
517
+ let panel = this.container.querySelector("#desktop-panel");
518
+ if (!panel) {
519
+ panel = document.createElement("div");
520
+ panel.id = "desktop-panel";
521
+ panel.innerHTML = `
522
+ <div class="xfce-menu-button">
523
+ <i class="fa-solid fa-paw xfce-logo"></i>
524
+ Applications
525
+ </div>
526
+ <div class="xfce-window-list"></div>
527
+ <div class="xfce-tray">
528
+ <span class="xfce-tray-icon" title="Network"><i class="fa-solid fa-wifi"></i></span>
529
+ <span class="xfce-tray-icon" title="Volume"><i class="fa-solid fa-volume-high"></i></span>
530
+ </div>
531
+ <div class="xfce-clock">
532
+ <span class="xfce-clock-time"></span>
533
+ <span class="xfce-clock-date"></span>
534
+ </div>
535
+ `;
536
+ this.container.prepend(panel);
537
+ // Delegated click on window list — attached once
538
+ const list = panel.querySelector(".xfce-window-list");
539
+ list.addEventListener("click", (e) => {
540
+ e.stopPropagation();
541
+ const btn = e.target.closest(".xfce-taskbutton");
542
+ if (!btn)
543
+ return;
544
+ const id = btn.getAttribute("data-win-id");
545
+ if (!id)
546
+ return;
547
+ const w = this.windows.find((ww) => ww.id === id);
548
+ if (!w)
549
+ return;
550
+ if (w.focused && !w.minimized) {
551
+ this.toggleMinimize(id);
552
+ }
553
+ else {
554
+ this.focusWindow(id);
555
+ }
556
+ });
557
+ }
558
+ // Update only what changes: task buttons, clock, menu
559
+ const list = panel.querySelector(".xfce-window-list");
560
+ list.innerHTML = this.windows.map((w) => `<span class="xfce-taskbutton${w.focused ? " active" : ""}" data-win-id="${w.id}">${this.escapeHtml(w.title)}</span>`).join("");
561
+ const now = new Date();
562
+ const timeEl = panel.querySelector(".xfce-clock-time");
563
+ const dateEl = panel.querySelector(".xfce-clock-date");
564
+ if (timeEl)
565
+ timeEl.textContent = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
566
+ if (dateEl)
567
+ dateEl.textContent = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
568
+ let menu = panel.querySelector(".xfce-menu");
569
+ if (this.menuOpen && !menu) {
570
+ menu = document.createElement("div");
571
+ menu.className = "xfce-menu";
572
+ menu.innerHTML = `
573
+ <div class="menu-category">System</div>
574
+ <div class="menu-item" data-action="terminal"><span class="menu-item-icon"><i class="fa-solid fa-terminal"></i></span>Terminal</div>
575
+ <div class="menu-item" data-action="thunar"><span class="menu-item-icon"><i class="fa-solid fa-folder-open"></i></span>File Manager</div>
576
+ <div class="menu-item" data-action="editor"><span class="menu-item-icon"><i class="fa-solid fa-file-pen"></i></span>Text Editor</div>
577
+ <div class="menu-separator"></div>
578
+ <div class="menu-item" data-action="about"><span class="menu-item-icon"><i class="fa-solid fa-circle-info"></i></span>About Fortune GNU/Linux</div>
579
+ <div class="menu-separator"></div>
580
+ <div class="menu-item" data-action="logout"><span class="menu-item-icon"><i class="fa-solid fa-power-off"></i></span>Log Out</div>
581
+ `;
582
+ panel.appendChild(menu);
583
+ }
584
+ else if (!this.menuOpen && menu) {
585
+ menu.remove();
586
+ }
587
+ }
588
+ renderDesktopIcons() {
589
+ let area = this.container.querySelector("#desktop-area");
590
+ if (!area) {
591
+ area = document.createElement("div");
592
+ area.id = "desktop-area";
593
+ this.container.appendChild(area);
594
+ }
595
+ area.innerHTML = `
596
+ <div class="desktop-icon" data-action="terminal">
597
+ <div class="desktop-icon-img term-icon"><i class="fa-solid fa-terminal"></i></div>
598
+ <span>Terminal</span>
599
+ </div>
600
+ <div class="desktop-icon" data-action="home">
601
+ <div class="desktop-icon-img home-icon"><i class="fa-solid fa-folder-open"></i></div>
602
+ <span>Home</span>
603
+ </div>
604
+ <div class="desktop-icon" data-action="editor">
605
+ <div class="desktop-icon-img editor-icon"><i class="fa-solid fa-file-pen"></i></div>
606
+ <span>Text Editor</span>
607
+ </div>
608
+ <div class="desktop-icon" data-action="trash">
609
+ <div class="desktop-icon-img trash-icon"><i class="fa-solid fa-trash-can"></i></div>
610
+ <span>Trash</span>
611
+ </div>
612
+ `;
613
+ }
614
+ renderWindows() {
615
+ const existing = this.container.querySelectorAll(".desktop-window");
616
+ for (const el of existing) {
617
+ const id = el.getAttribute("data-win-id");
618
+ if (!id || !this.windows.some((w) => w.id === id && !w.minimized)) {
619
+ el.remove();
620
+ }
621
+ }
622
+ for (const w of this.windows) {
623
+ if (w.minimized) {
624
+ const el = this.container.querySelector(`.desktop-window[data-win-id="${w.id}"]`);
625
+ if (el)
626
+ el.remove();
627
+ }
628
+ else {
629
+ this.renderWindowElement(w);
630
+ }
631
+ }
632
+ }
633
+ renderWindowPositions() {
634
+ for (const w of this.windows) {
635
+ if (w.minimized)
636
+ continue;
637
+ const el = this.container.querySelector(`.desktop-window[data-win-id="${w.id}"]`);
638
+ if (!el)
639
+ continue;
640
+ el.style.left = `${w.x}px`;
641
+ el.style.top = `${w.y}px`;
642
+ }
643
+ }
644
+ renderTerminalContentById(winId) {
645
+ const w = this.windows.find((ww) => ww.id === winId);
646
+ if (!w || w.content.type !== "terminal")
647
+ return;
648
+ const el = this.container.querySelector(`.desktop-window[data-win-id="${winId}"] .win-content`);
649
+ if (!el)
650
+ return;
651
+ w.content.preEl = w.content.preEl ?? document.createElement("pre");
652
+ const pre = w.content.preEl;
653
+ pre.className = "win-terminal";
654
+ pre.innerHTML = w.content.termRenderer.renderHtml();
655
+ if (!pre.parentNode)
656
+ el.appendChild(pre);
657
+ }
658
+ renderThunarContent(el, content) {
659
+ const contentArea = el.querySelector(".win-content");
660
+ if (!contentArea)
661
+ return;
662
+ const targetPath = content.path;
663
+ if (contentArea.getAttribute("data-thunar-path") === targetPath)
664
+ return;
665
+ contentArea.setAttribute("data-thunar-path", targetPath);
666
+ const parentPath = targetPath === "/" ? null : targetPath.replace(/\/[^/]+$/, "") || "/";
667
+ const parentEntry = parentPath
668
+ ? `<div class="thunar-entry" data-path="${this.escapeHtml(parentPath)}" data-type="directory"><span class="thunar-icon"><i class="fa-solid fa-folder"></i></span><span>..</span></div>`
669
+ : "";
670
+ let listing = "";
671
+ try {
672
+ const entries = this.shell.vfs.list(targetPath);
673
+ listing = entries
674
+ .filter((e) => e !== "." && e !== "..")
675
+ .map((e) => {
676
+ try {
677
+ const st = this.shell.vfs.stat(`${targetPath}/${e}`);
678
+ const icon = st.type === "directory"
679
+ ? `<i class="fa-solid fa-folder"></i>`
680
+ : `<i class="fa-regular fa-file"></i>`;
681
+ const fullPath = `${targetPath}/${e}`;
682
+ return `<div class="thunar-entry" data-path="${this.escapeHtml(fullPath)}" data-type="${st.type}"><span class="thunar-icon">${icon}</span><span>${this.escapeHtml(e)}</span></div>`;
683
+ }
684
+ catch {
685
+ return `<div class="thunar-entry"><span class="thunar-icon"><i class="fa-solid fa-circle-question"></i></span><span>${this.escapeHtml(e)}</span></div>`;
686
+ }
687
+ })
688
+ .join("");
689
+ }
690
+ catch {
691
+ listing = `<div class="thunar-error">Could not read ${this.escapeHtml(targetPath)}</div>`;
692
+ }
693
+ contentArea.innerHTML = `
694
+ <div class="thunar-pathbar">Location: ${this.escapeHtml(targetPath)}</div>
695
+ <div class="thunar-listing">${parentEntry}${listing}</div>
696
+ `;
697
+ }
698
+ renderEditorContent(el, winId, content) {
699
+ const contentArea = el.querySelector(".win-content");
700
+ if (!contentArea)
701
+ return;
702
+ if (contentArea.querySelector(".editor-textarea"))
703
+ return;
704
+ let fileText = "";
705
+ try {
706
+ fileText = this.shell.vfs.readFile(content.path);
707
+ }
708
+ catch { /* new file */ }
709
+ contentArea.innerHTML = `
710
+ <div class="editor-toolbar">
711
+ <button class="editor-save-btn" data-win-id="${winId}">Save</button>
712
+ <span class="editor-path">${this.escapeHtml(content.path)}</span>
713
+ <span class="editor-dirty" data-win-id="${winId}" style="display:none">●</span>
714
+ </div>
715
+ <textarea class="editor-textarea" data-win-id="${winId}" spellcheck="false">${this.escapeHtml(fileText)}</textarea>
716
+ `;
717
+ const textarea = contentArea.querySelector(".editor-textarea");
718
+ const dirtyDot = contentArea.querySelector(".editor-dirty");
719
+ textarea.addEventListener("input", () => {
720
+ content.dirty = true;
721
+ dirtyDot.style.display = "";
722
+ const w = this.windows.find((ww) => ww.id === winId);
723
+ if (w && !w.title.startsWith("*"))
724
+ w.title = `*${w.title}`;
725
+ });
726
+ textarea.addEventListener("keydown", (e) => {
727
+ e.stopPropagation(); // don't send keys to terminal
728
+ if (e.ctrlKey && e.key === "s") {
729
+ e.preventDefault();
730
+ this.saveEditor(winId);
731
+ }
732
+ });
733
+ contentArea.querySelector(".editor-save-btn")?.addEventListener("click", (e) => {
734
+ e.stopPropagation();
735
+ this.saveEditor(winId);
736
+ });
737
+ }
738
+ saveEditor(winId) {
739
+ const w = this.windows.find((ww) => ww.id === winId);
740
+ if (!w || w.content.type !== "editor")
741
+ return;
742
+ const el = this.container.querySelector(`.desktop-window[data-win-id="${winId}"]`);
743
+ if (!el)
744
+ return;
745
+ const textarea = el.querySelector(".editor-textarea");
746
+ if (!textarea)
747
+ return;
748
+ try {
749
+ this.shell.vfs.writeFile(w.content.path, textarea.value);
750
+ w.content.dirty = false;
751
+ w.title = `Mousepad — ${w.content.path.split("/").pop()}`;
752
+ const dirtyDot = el.querySelector(".editor-dirty");
753
+ if (dirtyDot)
754
+ dirtyDot.style.display = "none";
755
+ const titleEl = el.querySelector(".win-title");
756
+ if (titleEl)
757
+ titleEl.textContent = w.title;
758
+ }
759
+ catch (err) {
760
+ console.error("editor save failed", err);
761
+ }
762
+ }
763
+ renderAboutContent(el) {
764
+ const contentArea = el.querySelector(".win-content");
765
+ if (!contentArea)
766
+ return;
767
+ contentArea.innerHTML = `
768
+ <div class="about-dialog">
769
+ <div class="about-logo"><i class="fa-brands fa-linux"></i></div>
770
+ <h2>Fortune GNU/Linux 1.0 Nyx</h2>
771
+ <p>A simulated Linux environment running entirely in your browser.</p>
772
+ <p>Kernel: ${this.shell.properties.kernel}</p>
773
+ <p>Architecture: ${this.shell.properties.arch}</p>
774
+ <p class="about-close-hint">Close this window to return</p>
775
+ </div>
776
+ `;
777
+ }
778
+ updateClock() {
779
+ const panel = this.container.querySelector("#desktop-panel");
780
+ if (!panel)
781
+ return;
782
+ const now = new Date();
783
+ const timeEl = panel.querySelector(".xfce-clock-time");
784
+ const dateEl = panel.querySelector(".xfce-clock-date");
785
+ if (timeEl)
786
+ timeEl.textContent = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
787
+ if (dateEl)
788
+ dateEl.textContent = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
789
+ }
790
+ showContextMenu(x, y, items) {
791
+ this.closeContextMenu();
792
+ const menu = document.createElement("div");
793
+ menu.className = "desktop-context-menu";
794
+ menu.style.left = `${x}px`;
795
+ menu.style.top = `${y}px`;
796
+ for (const item of items) {
797
+ const el = document.createElement("div");
798
+ el.className = `ctx-item${item.danger ? " ctx-danger" : ""}`;
799
+ el.innerHTML = `<i class="${item.icon}"></i><span>${this.escapeHtml(item.label)}</span>`;
800
+ el.addEventListener("click", (e) => { e.stopPropagation(); this.closeContextMenu(); item.action(); });
801
+ menu.appendChild(el);
802
+ }
803
+ this.container.appendChild(menu);
804
+ // Clamp to viewport
805
+ const rect = menu.getBoundingClientRect();
806
+ if (rect.right > window.innerWidth)
807
+ menu.style.left = `${x - rect.width}px`;
808
+ if (rect.bottom > window.innerHeight)
809
+ menu.style.top = `${y - rect.height}px`;
810
+ }
811
+ closeContextMenu() {
812
+ this.container.querySelector(".desktop-context-menu")?.remove();
813
+ }
814
+ ensureTrashDir() {
815
+ const parts = this.trashPath.split("/").filter(Boolean);
816
+ let cur = "";
817
+ for (const p of parts) {
818
+ cur += `/${p}`;
819
+ if (!this.shell.vfs.exists(cur))
820
+ this.shell.vfs.mkdir(cur, 0o700);
821
+ }
822
+ }
823
+ refreshThunarWindow(winId) {
824
+ if (!winId)
825
+ return;
826
+ const w = this.windows.find((ww) => ww.id === winId);
827
+ if (!w || w.content.type !== "thunar")
828
+ return;
829
+ const wEl = this.container.querySelector(`.desktop-window[data-win-id="${winId}"] .win-content`);
830
+ if (wEl)
831
+ wEl.removeAttribute("data-thunar-path");
832
+ this.renderWindowElement(w);
833
+ }
834
+ moveToTrash(path, winId) {
835
+ this.ensureTrashDir();
836
+ const name = path.split("/").pop() ?? "file";
837
+ let dest = `${this.trashPath}/${name}`;
838
+ let i = 1;
839
+ while (this.shell.vfs.exists(dest))
840
+ dest = `${this.trashPath}/${name}.${i++}`;
841
+ try {
842
+ const content = this.shell.vfs.readFile(path);
843
+ this.shell.vfs.writeFile(dest, content);
844
+ this.shell.vfs.remove(path);
845
+ }
846
+ catch {
847
+ // directory: not supported for now, just remove
848
+ try {
849
+ this.shell.vfs.remove(path, { recursive: true });
850
+ }
851
+ catch { /* ignore */ }
852
+ }
853
+ this.refreshThunarWindow(winId);
854
+ }
855
+ trashRestore(path, winId) {
856
+ const name = path.split("/").pop() ?? "file";
857
+ const dest = `/root/${name}`;
858
+ try {
859
+ const content = this.shell.vfs.readFile(path);
860
+ this.shell.vfs.writeFile(dest, content);
861
+ this.shell.vfs.remove(path);
862
+ }
863
+ catch { /* ignore */ }
864
+ this.refreshThunarWindow(winId);
865
+ }
866
+ trashDelete(path, winId) {
867
+ try {
868
+ this.shell.vfs.remove(path, { recursive: true });
869
+ }
870
+ catch { /* ignore */ }
871
+ this.refreshThunarWindow(winId);
872
+ }
873
+ renamePrompt(path, winId) {
874
+ const oldName = path.split("/").pop() ?? "";
875
+ const newName = window.prompt("Rename:", oldName);
876
+ if (!newName || newName === oldName)
877
+ return;
878
+ const dir = path.substring(0, path.lastIndexOf("/"));
879
+ const dest = `${dir}/${newName}`;
880
+ try {
881
+ const content = this.shell.vfs.readFile(path);
882
+ this.shell.vfs.writeFile(dest, content);
883
+ this.shell.vfs.remove(path);
884
+ }
885
+ catch { /* ignore */ }
886
+ this.refreshThunarWindow(winId);
887
+ }
888
+ escapeHtml(s) {
889
+ return s
890
+ .replace(/&/g, "&amp;")
891
+ .replace(/</g, "&lt;")
892
+ .replace(/>/g, "&gt;")
893
+ .replace(/"/g, "&quot;")
894
+ .replace(/'/g, "&#39;");
895
+ }
896
+ }