typescript-virtual-container 1.6.1 → 1.6.2

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