flea-qml 0.1.0 → 0.2.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/README.md +35 -2
- package/package.json +2 -2
- package/src/archive.js +5 -0
- package/src/archiveops.js +36 -5
- package/src/menu.js +2 -2
- package/src/meta.js +9 -3
- package/src/terminal-server.js +95 -0
- package/src/terminal-shell.js +26 -0
- package/src/trashbrowse.js +1 -0
- package/test/delete-trash.test.js +2 -2
- package/test/file-ops.test.js +66 -0
- package/test/file-url.test.js +24 -0
- package/test/manual-archive-here-ui.mjs +47 -0
- package/test/manual-folder-focus.mjs +63 -0
- package/test/manual-terminal-lifecycle.mjs +42 -0
- package/test/manual-terminal-navigation.mjs +48 -0
- package/test/manual-terminal-ui.mjs +47 -0
- package/test/trashbrowse.test.js +1 -1
- package/ui/ArchiveCards.qml +28 -1
- package/ui/ArchiveHereConfirm.qml +127 -0
- package/ui/Backend.qml +5 -1
- package/ui/ColumnPane.qml +2 -0
- package/ui/Opener.qml +16 -36
- package/ui/Pane.qml +25 -1
- package/ui/PaneRail.qml +32 -3
- package/ui/PaneWire.qml +2 -6
- package/ui/Preview.qml +3 -2
- package/ui/PreviewArchive.qml +26 -1
- package/ui/PreviewColumn.qml +2 -0
- package/ui/QuickLookArchive.qml +3 -0
- package/ui/SelectionPreview.qml +1 -1
- package/ui/Sidebar.qml +5 -9
- package/ui/TabBar.qml +3 -3
- package/ui/TerminalService.qml +69 -0
- package/ui/TerminalTab.qml +48 -0
- package/ui/TerminalTabs.qml +27 -0
- package/ui/WindowBody.qml +2 -0
- package/ui/boot/shell.qml +10 -1
- package/ui/js/Archive.js +1 -1
- package/ui/js/Drag.js +1 -5
- package/ui/js/Focus.js +2 -0
- package/ui/js/Format.js +10 -1
- package/ui/js/Keymap.js +2 -2
- package/ui/js/Messages.js +1 -1
- package/ui/js/Nav.js +1 -1
- package/ui/js/Tabs.js +15 -0
- package/ui/js/Tap.js +6 -0
- package/ui/js/Trash.js +4 -1
- package/ui/qmldir +2 -0
package/README.md
CHANGED
|
@@ -9,16 +9,32 @@ Flea QML is a cross-platform file manager written in Bun JavaScript & QML. It's
|
|
|
9
9
|
- **Extend the capabilities**: run across platforms, on Windows, Linux and
|
|
10
10
|
Android, wherever a QML runtime and a JS engine do, including [buninu-browser](https://github.com/jjtseng93/buninu-browser)'s QML frontend.
|
|
11
11
|
|
|
12
|
+
> [!WARNING]
|
|
13
|
+
> Flea QML is still in early development. Features and APIs may be unstable and may change without notice.
|
|
14
|
+
|
|
12
15
|
## Running it
|
|
13
16
|
|
|
14
|
-
Install Bun first, then check that it is available on PATH:
|
|
17
|
+
1. Install Bun first, then check that it is available on PATH:
|
|
15
18
|
|
|
16
19
|
```sh
|
|
17
20
|
npm i -g bun
|
|
18
21
|
bun --version
|
|
19
22
|
```
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
2. Enter the [Buninu userspace](https://github.com/jjtseng93/buninu)
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
# Run a server & open in a browser
|
|
28
|
+
bunx buninu
|
|
29
|
+
|
|
30
|
+
# Open in the current terminal
|
|
31
|
+
# Kitty graphics support is required if you want to show Flea QML inside it directly
|
|
32
|
+
bunx buninu --local
|
|
33
|
+
|
|
34
|
+
# Or do nothing in a @drxiaozhi/minapk Android App
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
3. Start Flea QML with npx or bunx:
|
|
22
38
|
|
|
23
39
|
```sh
|
|
24
40
|
npx flea-qml
|
|
@@ -123,6 +139,11 @@ frontend may intercept a shortcut before Flea receives it.
|
|
|
123
139
|
| `,`, Ctrl+, | Open settings |
|
|
124
140
|
| `?` | Open the searchable keymap |
|
|
125
141
|
|
|
142
|
+
Enter on an ordinary file invokes `xdg-open` from PATH on every platform,
|
|
143
|
+
including Windows. Install or provide an `xdg-open` command there; Flea does not
|
|
144
|
+
substitute a platform-specific opener. Folders navigate inside Flea and archives
|
|
145
|
+
open the built-in preview (except AppImage). Space uses the built-in preview.
|
|
146
|
+
|
|
126
147
|
### Selection and file operations
|
|
127
148
|
|
|
128
149
|
| Keys | Action |
|
|
@@ -188,6 +209,18 @@ Esc acts on that query rather than the other one.
|
|
|
188
209
|
| `e` in PDF Quick Look | Toggle expanded view |
|
|
189
210
|
| `y` in Compare Quick Look | Copy the diff patch |
|
|
190
211
|
|
|
212
|
+
### Extracting archives
|
|
213
|
+
|
|
214
|
+
Both Quick Look and the right preview column offer **Extract to Folder** and
|
|
215
|
+
**Extract here** for ZIP, TAR, TAR.GZ/TGZ and TAR.ZST/TZST archives. Extract to
|
|
216
|
+
Folder uses the archive name without its extension; existing destinations get
|
|
217
|
+
` copy`, ` copy 2`, and so on. Extract here places the contents beside the archive
|
|
218
|
+
and asks for confirmation when the archive contains more than five top-level items
|
|
219
|
+
(files and directories; nested contents count only toward their top-level folder). Cancel is the default.
|
|
220
|
+
Existing items are kept: colliding names get a copy suffix, and existing folders
|
|
221
|
+
are not merged. Work is prepared under a hidden unique name at the destination,
|
|
222
|
+
not in the system temporary directory, so publication stays on the same filesystem.
|
|
223
|
+
|
|
191
224
|
### Menus and dialogs
|
|
192
225
|
|
|
193
226
|
Use Up/Down (or `k`/`j`) to choose a menu row, Right/`l` to enter a submenu,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flea-qml",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Flea QML is a cross-platform file manager written in Bun JavaScript & QML. It's an unofficial fork of Flea, the fast GUI file manager for Omarchy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"start": "bun index.js",
|
|
33
33
|
"test": "bun test",
|
|
34
|
-
"browser": "bunx buninu-browser@0.10.
|
|
34
|
+
"browser": "bunx buninu-browser@0.10.5"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/archive.js
CHANGED
|
@@ -400,3 +400,8 @@ export function openZip(path) {
|
|
|
400
400
|
// The pieces src/archiveops.js reads member data with: extraction needs the type, mode, link and
|
|
401
401
|
// bytes of every member, which the listing above never keeps.
|
|
402
402
|
export { BLOCK, MAX_RECORD, checkedSize, checksumMatches, fileReader, kindOf, octal, paxRecords, scanZip, text };
|
|
403
|
+
|
|
404
|
+
// Extraction normalizes both separators and ignores empty/dot path components.
|
|
405
|
+
export function topLevelArchiveName(name) {
|
|
406
|
+
return String(name).replaceAll("\\", "/").split("/").find(part => part && part !== ".") || "";
|
|
407
|
+
}
|
package/src/archiveops.js
CHANGED
|
@@ -15,7 +15,7 @@ import { basename, dirname, join, isAbsolute, resolve, sep } from "node:path";
|
|
|
15
15
|
import { Readable } from "node:stream";
|
|
16
16
|
import { pipeline } from "node:stream/promises";
|
|
17
17
|
import { crc32, createDeflateRaw, createGunzip, createGzip, createInflateRaw, createZstdCompress, createZstdDecompress } from "node:zlib";
|
|
18
|
-
import { BLOCK, MAX_RECORD, checkedSize, checksumMatches, fileReader, octal, paxRecords, scanZip, text } from "./archive.js";
|
|
18
|
+
import { BLOCK, MAX_RECORD, listArchive, topLevelArchiveName, checkedSize, checksumMatches, fileReader, octal, paxRecords, scanZip, text } from "./archive.js";
|
|
19
19
|
import { capturedPaths } from "./menu.js";
|
|
20
20
|
import { copyName, nextJobId, reason } from "./transfer.js";
|
|
21
21
|
|
|
@@ -439,25 +439,56 @@ function extract(request, send, id, refuse) {
|
|
|
439
439
|
send({ t: "archivestarted", id });
|
|
440
440
|
send({ t: "extractstarted", id });
|
|
441
441
|
send({ t: "transferprogress", id, index: 0, name: basename(path), bytes: 0, total: 0, scanned: 0 });
|
|
442
|
-
const
|
|
442
|
+
const here = request.here === true;
|
|
443
|
+
const staged = stagingName(here ? join(dest, "contents") : dest, "ext", id);
|
|
444
|
+
let ownsStaged = false;
|
|
443
445
|
(async () => {
|
|
446
|
+
if (here) {
|
|
447
|
+
if (!lstatOrNull(dest)?.isDirectory()) throw Error("Extract here needs an existing real folder");
|
|
448
|
+
if (request.confirmed !== true) {
|
|
449
|
+
const entries = new Set();
|
|
450
|
+
await listArchive(path, path, member => {
|
|
451
|
+
check(job);
|
|
452
|
+
const root = topLevelArchiveName(member.name);
|
|
453
|
+
if (root) entries.add(root);
|
|
454
|
+
if (entries.size > 5) throw Error("Extract here needs confirmation for more than 5 top-level items");
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
444
458
|
await fsp.mkdir(staged, { mode: 0o700 });
|
|
459
|
+
ownsStaged = true;
|
|
445
460
|
const out = new Extractor(staged, job);
|
|
446
461
|
if (kind === "zip") await readZip(path, out, job);
|
|
447
462
|
else await readTar(path, kind, out, job);
|
|
448
463
|
await out.finish();
|
|
449
464
|
check(job);
|
|
450
|
-
|
|
465
|
+
let written = dest;
|
|
466
|
+
if (here) {
|
|
467
|
+
// Publish complete top-level items without merging existing folders. Roll back our
|
|
468
|
+
// moves if publishing fails; cancellation is checked before this short commit phase.
|
|
469
|
+
const moved = [];
|
|
470
|
+
try {
|
|
471
|
+
for (const name of await fsp.readdir(staged)) {
|
|
472
|
+
const source = join(staged, name);
|
|
473
|
+
const target = await publish(source, join(dest, name), lstatOrNull(source)?.isDirectory() ? null : "");
|
|
474
|
+
moved.push([source, target]);
|
|
475
|
+
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
for (const [source, target] of moved.reverse()) await fsp.rename(target, source);
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
await fsp.rmdir(staged);
|
|
481
|
+
} else written = await publish(staged, dest, null);
|
|
451
482
|
send({ t: "archivedone", id, ok: true, verified: true, err: "", path: slash(path), dest: slash(written) });
|
|
452
483
|
})()
|
|
453
484
|
.catch(async (e) => {
|
|
454
|
-
await fsp.rm(staged, { recursive: true, force: true }).catch(() => {});
|
|
485
|
+
if (ownsStaged) await fsp.rm(staged, { recursive: true, force: true }).catch(() => {});
|
|
455
486
|
send({ t: "archivedone", id, ok: false, verified: true, path: slash(path), dest: slash(dest),
|
|
456
487
|
err: e instanceof Cancelled ? "cancelled" : e?.sentence ?? reason(e) });
|
|
457
488
|
})
|
|
458
489
|
.finally(() => {
|
|
459
490
|
jobs.delete(id);
|
|
460
|
-
send({ t: "changed", path: slash(dirname(dest)) });
|
|
491
|
+
send({ t: "changed", path: slash(here ? dest : dirname(dest)) });
|
|
461
492
|
});
|
|
462
493
|
}
|
|
463
494
|
|
package/src/menu.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// waiting.
|
|
9
9
|
import {trashRecord,forgetTrashRecord} from './trash-record.js';
|
|
10
10
|
import { constants, lstatSync, readdirSync, rmSync } from "node:fs";
|
|
11
|
-
import { dirname, join, resolve, isAbsolute } from "node:path";
|
|
11
|
+
import { basename, dirname, join, resolve, isAbsolute } from "node:path";
|
|
12
12
|
|
|
13
13
|
const CHANGED = "Selected items changed; reopen the menu.";
|
|
14
14
|
|
|
@@ -112,7 +112,7 @@ export function menuaction(request, send, listing, stamp) {
|
|
|
112
112
|
held.review = reviewTrees(held.paths);
|
|
113
113
|
if (!unchanged(held)) throw new Error(CHANGED);
|
|
114
114
|
held.token = ++deleteTokens;
|
|
115
|
-
return answer({ ok: true, token: held.token, count: held.paths.length, bytes: held.review.bytes, partial: false });
|
|
115
|
+
return answer({ ok: true, token: held.token, count: held.paths.length, name: held.paths.length === 1 ? basename(held.paths[0]) : "", bytes: held.review.bytes, partial: false });
|
|
116
116
|
} catch (error) { return answer({ ok: false, error: error.message }); }
|
|
117
117
|
}
|
|
118
118
|
if (op === "checkDelete") {
|
package/src/meta.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Not read yet: media duration and rate, EXIF orientation and camera facts, RAW previews, owners.
|
|
5
5
|
import { closeSync, fstatSync, lstatSync, openSync, readSync, readlinkSync, statSync, constants } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { listArchive } from "./archive.js";
|
|
7
|
+
import { listArchive, topLevelArchiveName } from "./archive.js";
|
|
8
8
|
import { officeKind, officePreview } from "./office.js";
|
|
9
9
|
|
|
10
10
|
// A line count stops here and says it is a floor (`partial`).
|
|
@@ -98,10 +98,16 @@ function addEntry(meta, name, isDir, size) {
|
|
|
98
98
|
// Every member through src/archive.js, which reads a zip's directory and a tar's headers only.
|
|
99
99
|
async function listMembers(path, name, meta) {
|
|
100
100
|
try {
|
|
101
|
-
|
|
101
|
+
const roots = new Set();
|
|
102
|
+
await listArchive(path, name, member => {
|
|
103
|
+
addEntry(meta, member.name, member.dir, member.size);
|
|
104
|
+
const root = topLevelArchiveName(member.name);
|
|
105
|
+
if (root) roots.add(root);
|
|
106
|
+
});
|
|
107
|
+
meta.topEntries = roots.size;
|
|
102
108
|
} catch {
|
|
103
109
|
// A failed read carries nothing, so a partial count never reads as a total.
|
|
104
|
-
Object.assign(meta, { entries: 0, unpacked: 0, names: [], afailed: true });
|
|
110
|
+
Object.assign(meta, { entries: 0, topEntries: -1, unpacked: 0, names: [], afailed: true });
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A shared, loopback-only server. Each tab owns a reconnect token, not a server.
|
|
2
|
+
// stdin is the GUI's lifetime lease; EOF also closes all PTYs after a GUI crash.
|
|
3
|
+
import {existsSync} from 'node:fs';
|
|
4
|
+
import {resolve, join, isAbsolute} from 'node:path';
|
|
5
|
+
import {spawn} from 'node:child_process';
|
|
6
|
+
import {createInterface} from 'node:readline';
|
|
7
|
+
export const RANDOM_URL_LENGTH = 48;
|
|
8
|
+
export function terminalCommand(which = Bun.which, exists = existsSync, repo = resolve(import.meta.dir, '..')) {
|
|
9
|
+
const sibling = join(repo, '..', 'jsgotty', 'gotty.js');
|
|
10
|
+
if (exists(sibling)) return [which('bun') || process.execPath, sibling];
|
|
11
|
+
const executable = which('jsgotty');
|
|
12
|
+
return executable ? [executable] : null;
|
|
13
|
+
}
|
|
14
|
+
export function terminalArguments() {
|
|
15
|
+
return ['--address','127.0.0.1','--port','0','--permit-write','--random-url',
|
|
16
|
+
'--random-url-length',String(RANDOM_URL_LENGTH),'--session','--permit-arguments',
|
|
17
|
+
'--close-signal','SIGTERM','--close-timeout','1',
|
|
18
|
+
// QtWebEngine's isolated iframe sends Origin:null. No arbitrary site origins.
|
|
19
|
+
'--ws-origin','^(|null|http://127\\.0\\.0\\.1:[0-9]+)$',
|
|
20
|
+
Bun.which('bun') || process.execPath, join(import.meta.dir,'terminal-shell.js')];
|
|
21
|
+
}
|
|
22
|
+
// Use jsgotty's existing WebTTY session protocol rather than scraping a session list.
|
|
23
|
+
export function sessionRequest(base, init, type) {
|
|
24
|
+
return new Promise((resolveRequest,reject) => {
|
|
25
|
+
const ws = new WebSocket(base.replace(/^http/,'ws')+'ws','webtty');
|
|
26
|
+
let done=false;
|
|
27
|
+
const timer=setTimeout(()=>finish(Error('Terminal session request timed out')),15000);
|
|
28
|
+
function finish(error,value){if(done)return;done=true;clearTimeout(timer);ws.close();error?reject(error):resolveRequest(value);}
|
|
29
|
+
ws.onopen=()=>ws.send(JSON.stringify({AuthToken:'',ReadOnly:true,...init}));
|
|
30
|
+
ws.onmessage=event=>{
|
|
31
|
+
const text=String(event.data);
|
|
32
|
+
if(text[0]!==type)return;
|
|
33
|
+
try {const result=JSON.parse(text.slice(1)); if(result.ok===false)throw Error(result.message);finish(null,result);} catch(e){finish(e);}
|
|
34
|
+
};
|
|
35
|
+
ws.onerror=()=>finish(Error('Terminal session connection failed'));
|
|
36
|
+
ws.onclose=()=>finish(Error('Terminal session connection closed'));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function runTerminalServer() {
|
|
40
|
+
const send=value=>{try{process.stdout.write(JSON.stringify(value)+'\n');}catch{}};
|
|
41
|
+
const command=terminalCommand();
|
|
42
|
+
if(!command){send({error:'jsgotty was not found. Install it on PATH (npm i -g jsgotty).'});return 1;}
|
|
43
|
+
let child,stopping=false,killer,buffer='',base='';
|
|
44
|
+
const tabs=new Map();
|
|
45
|
+
let readyResolve,readyReject;
|
|
46
|
+
const ready=new Promise((res,rej)=>{readyResolve=res;readyReject=rej;});ready.catch(()=>{});
|
|
47
|
+
const stop=()=>{
|
|
48
|
+
if(stopping)return;stopping=true;readyReject(Error('Terminal server stopped'));
|
|
49
|
+
child?.kill('SIGTERM');killer=setTimeout(()=>child?.kill('SIGKILL'),7000);killer.unref();
|
|
50
|
+
};
|
|
51
|
+
const closeSession=async token=>{if(base&&token&&!stopping)await sessionRequest(base,{ReconnectToken:token,TerminateSession:true},'b');};
|
|
52
|
+
const lines=createInterface({input:process.stdin,crlfDelay:Infinity});
|
|
53
|
+
lines.on('line',async line=>{
|
|
54
|
+
let request;
|
|
55
|
+
try {
|
|
56
|
+
request=JSON.parse(line);
|
|
57
|
+
if(request.shutdown===true){stop();return;}
|
|
58
|
+
if(typeof request.id!=='string'||request.id.length>128)throw Error('Invalid terminal tab id');
|
|
59
|
+
if(request.close){const record=tabs.get(request.id);tabs.delete(request.id);if(record)await closeSession(record.token);return;}
|
|
60
|
+
if(typeof request.path!=='string'||!isAbsolute(request.path))throw Error('An absolute terminal directory is required');
|
|
61
|
+
if(tabs.has(request.id))return;
|
|
62
|
+
const record={token:''};tabs.set(request.id,record);
|
|
63
|
+
await ready;
|
|
64
|
+
if(tabs.get(request.id)!==record||stopping)return;
|
|
65
|
+
const args=new URLSearchParams({arg:request.path});
|
|
66
|
+
const session=await sessionRequest(base,{Arguments:'?'+args},'5');
|
|
67
|
+
record.token=session.token;
|
|
68
|
+
if(tabs.get(request.id)!==record||stopping){await closeSession(record.token);return;}
|
|
69
|
+
send({id:request.id,url:base+'?reconnect-token='+encodeURIComponent(record.token),pid:session.pid});
|
|
70
|
+
} catch(error){send({id:request?.id,error:error.message});}
|
|
71
|
+
});
|
|
72
|
+
lines.on('close',stop);process.on('SIGTERM',stop);process.on('SIGINT',stop);
|
|
73
|
+
try {
|
|
74
|
+
child=spawn(command[0],[...command.slice(1),...terminalArguments()],{stdio:['pipe','pipe','pipe'],windowsHide:true});
|
|
75
|
+
const read=chunk=>{
|
|
76
|
+
buffer=(buffer+chunk.toString()).slice(-16384);
|
|
77
|
+
if(base)return;
|
|
78
|
+
const match=buffer.match(/http:\/\/127\.0\.0\.1:(\d+)\/([A-Za-z0-9]{48})\//);
|
|
79
|
+
if(match&&+match[1]>0&&+match[1]<=65535){base=match[0];readyResolve();send({ready:true});}
|
|
80
|
+
};
|
|
81
|
+
child.stdout.on('data',read);child.stderr.on('data',read);
|
|
82
|
+
return await new Promise(resolveExit=>{
|
|
83
|
+
child.on('error',error=>{readyReject(error);send({error:error.message});resolveExit(1);});
|
|
84
|
+
child.on('close',code=>{
|
|
85
|
+
readyReject(Error('Terminal server exited'));
|
|
86
|
+
if(!stopping)send({error:base?'Terminal server exited.':'jsgotty failed to start: '+buffer.slice(-2000)});
|
|
87
|
+
resolveExit(stopping?0:code||1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(killer);lines.removeAllListeners();lines.close();process.stdin.pause();
|
|
92
|
+
process.removeListener('SIGTERM',stop);process.removeListener('SIGINT',stop);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if(import.meta.main)process.exit(await runTerminalServer());
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// jsgotty appends the requested tab directory as one argv entry, never shell text.
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {existsSync} from 'node:fs';
|
|
4
|
+
import {join, isAbsolute} from 'node:path';
|
|
5
|
+
export function shellCommand(which = Bun.which, exists = existsSync, platform = process.platform) {
|
|
6
|
+
const sibling = join(import.meta.dir, '../../bunmsh/index.js');
|
|
7
|
+
if (exists(sibling)) return [which('bun') || process.execPath, sibling];
|
|
8
|
+
const bunmsh = which('bunmsh');
|
|
9
|
+
if (bunmsh) return [bunmsh];
|
|
10
|
+
if (platform !== 'win32') return ['/bin/sh'];
|
|
11
|
+
for (const name of ['powershell', 'pwsh']) {
|
|
12
|
+
const path = which(name);
|
|
13
|
+
if (path) return [path];
|
|
14
|
+
}
|
|
15
|
+
throw Error('No shell was found on PATH. Install bunmsh, PowerShell or pwsh.');
|
|
16
|
+
}
|
|
17
|
+
if (import.meta.main) {
|
|
18
|
+
const cwd = process.argv[2];
|
|
19
|
+
if (!cwd || !isAbsolute(cwd)) throw Error('An absolute terminal directory is required');
|
|
20
|
+
process.chdir(cwd);
|
|
21
|
+
const command = shellCommand();
|
|
22
|
+
const child = spawn(command[0], command.slice(1), {stdio:'inherit', windowsHide:true});
|
|
23
|
+
for (const signal of ['SIGTERM','SIGINT','SIGHUP']) process.on(signal, () => child.kill(signal));
|
|
24
|
+
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
|
|
25
|
+
child.on('exit', code => process.exit(code || 0));
|
|
26
|
+
}
|
package/src/trashbrowse.js
CHANGED
|
@@ -280,6 +280,7 @@ function handleTrashBrowse(request, send) {
|
|
|
280
280
|
token: review.token,
|
|
281
281
|
all: request.emptyTrash === true || request.all === true,
|
|
282
282
|
count: review.items.length,
|
|
283
|
+
name: review.items.length === 1 ? (review.items[0].original.split(/[\\/]/).pop() || review.items[0].name) : "",
|
|
283
284
|
bytes: tree.bytes,
|
|
284
285
|
partial: false,
|
|
285
286
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {test,expect,afterAll} from 'bun:test';
|
|
2
2
|
import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,existsSync,renameSync,symlinkSync,lstatSync,rmSync,readdirSync} from 'node:fs';
|
|
3
3
|
import {tmpdir} from 'node:os';
|
|
4
|
-
import {join} from 'node:path';
|
|
4
|
+
import {join,basename} from 'node:path';
|
|
5
5
|
import {menuaction} from '../src/menu.js';
|
|
6
6
|
import {trashOne,trash} from '../src/trash.js';
|
|
7
7
|
const root=mkdtempSync(join(tmpdir(),'flea-mutations-'));
|
|
@@ -17,7 +17,7 @@ function fixture() {
|
|
|
17
17
|
}
|
|
18
18
|
test('permanent delete requires a prepared token and deletes reviewed tree once',()=>{
|
|
19
19
|
const {path,call}=fixture();expect(call('checkDelete').valid).toBe(false);expect(call('delete').ok).toBe(false);
|
|
20
|
-
const
|
|
20
|
+
const prepared=call('prepareDelete');expect(prepared.name).toBe(basename(path));const {token}=prepared;expect(call('checkDelete',{token}).valid).toBe(true);
|
|
21
21
|
call('delete',{token});expect(existsSync(path)).toBe(false);expect(call('delete',{token}).ok).toBe(false);
|
|
22
22
|
});
|
|
23
23
|
test('new descendants invalidate confirmation before any removal',()=>{
|
package/test/file-ops.test.js
CHANGED
|
@@ -217,6 +217,11 @@ test('an archive that names a path outside its folder extracts nothing', async (
|
|
|
217
217
|
expect(done.err).toMatch(/outside its folder/);
|
|
218
218
|
expect(readdirSync(dir).sort()).toEqual(['evil.tar']);
|
|
219
219
|
expect(existsSync(join(root, 'escape'))).toBe(false);
|
|
220
|
+
const here = collector();
|
|
221
|
+
archive({ op: 'extract', path: join(dir, 'evil.tar'), dest: dir, here: true, confirmed: true }, here.send);
|
|
222
|
+
expect((await until(here.lines, l => l.t === 'archivedone')).ok).toBe(false);
|
|
223
|
+
expect(readdirSync(dir)).toEqual(['evil.tar']);
|
|
224
|
+
expect(existsSync(join(root, 'escape'))).toBe(false);
|
|
220
225
|
});
|
|
221
226
|
|
|
222
227
|
test('a password, a format it cannot read and a disk image are refused without starting', () => {
|
|
@@ -239,3 +244,64 @@ test('cancelling a compress removes its staging file', async () => {
|
|
|
239
244
|
expect(done).toMatchObject({ ok: false, err: 'cancelled' });
|
|
240
245
|
expect(readdirSync(dir)).toEqual(['big']);
|
|
241
246
|
});
|
|
247
|
+
|
|
248
|
+
for (const count of [5, 6]) test(`extract here ${count} items requires confirmation only above five`, async () => {
|
|
249
|
+
const dir = fresh(), input = join(dir, 'input'), dest = join(dir, 'destination');
|
|
250
|
+
mkdirSync(input); mkdirSync(dest);
|
|
251
|
+
const paths = Array.from({ length: count }, (_, i) => {
|
|
252
|
+
const path = join(input, `item${i}.txt`); writeFileSync(path, `content ${i}`); return path;
|
|
253
|
+
});
|
|
254
|
+
const pack = collector();
|
|
255
|
+
archive({ op: 'compress', paths, dest: join(dir, 'pack.zip'), format: 'zip' }, pack.send);
|
|
256
|
+
const packed = await until(pack.lines, l => l.t === 'archivedone');
|
|
257
|
+
expect(packed.ok).toBe(true);
|
|
258
|
+
writeFileSync(join(dest, 'item0.txt'), 'keep');
|
|
259
|
+
const run = async confirmed => {
|
|
260
|
+
const c = collector();
|
|
261
|
+
archive({ op: 'extract', path: packed.path, dest, here: true, confirmed }, c.send);
|
|
262
|
+
return await until(c.lines, l => l.t === 'archivedone');
|
|
263
|
+
};
|
|
264
|
+
const first = await run(false);
|
|
265
|
+
expect(first.ok).toBe(count <= 5);
|
|
266
|
+
if (count > 5) {
|
|
267
|
+
expect(first.err).toContain('confirmation');
|
|
268
|
+
expect(readdirSync(dest)).toEqual(['item0.txt']);
|
|
269
|
+
expect((await run(true)).ok).toBe(true);
|
|
270
|
+
}
|
|
271
|
+
expect(readFileSync(join(dest, 'item0.txt'), 'utf8')).toBe('keep');
|
|
272
|
+
expect(readdirSync(dest).length).toBe(count + 1);
|
|
273
|
+
expect(readdirSync(dest).some(n => n.startsWith('.flea-work'))).toBe(false);
|
|
274
|
+
expect(readdirSync(dest).filter(n => n !== 'item0.txt').map(n => readFileSync(join(dest, n), 'utf8')).sort()).toEqual(paths.map((_, i) => `content ${i}`).sort());
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('extract here never merges an existing folder; folder extraction keeps copy naming', async () => {
|
|
278
|
+
const dir = fresh(), input = join(dir, 'folder'), dest = join(dir, 'out');
|
|
279
|
+
mkdirSync(input); writeFileSync(join(input, 'new.txt'), 'new');
|
|
280
|
+
mkdirSync(dest); mkdirSync(join(dest, 'folder')); writeFileSync(join(dest, 'folder', 'old.txt'), 'old');
|
|
281
|
+
const c = collector();
|
|
282
|
+
archive({ op: 'compress', paths: [input], dest: join(dir, 'pack.tar'), format: 'tar' }, c.send);
|
|
283
|
+
const packed = await until(c.lines, l => l.t === 'archivedone');
|
|
284
|
+
for (const here of [true, false]) {
|
|
285
|
+
const result = collector();
|
|
286
|
+
archive({ op: 'extract', path: packed.path, dest, here, confirmed: true }, result.send);
|
|
287
|
+
expect((await until(result.lines, l => l.t === 'archivedone')).ok).toBe(true);
|
|
288
|
+
}
|
|
289
|
+
expect(readFileSync(join(dest, 'folder', 'old.txt'), 'utf8')).toBe('old');
|
|
290
|
+
expect(existsSync(join(dest, 'folder', 'new.txt'))).toBe(false);
|
|
291
|
+
expect(readFileSync(join(dest, 'folder copy', 'new.txt'), 'utf8')).toBe('new');
|
|
292
|
+
expect(readFileSync(join(dir, 'out copy', 'folder', 'new.txt'), 'utf8')).toBe('new');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
for (const format of ['zip','tar']) test(`extract here counts top-level names, not nested members (${format})`, async () => {
|
|
296
|
+
const dir=fresh(), input=join(dir,'one-folder'), dest=join(dir,'destination');
|
|
297
|
+
mkdirSync(input);mkdirSync(dest);
|
|
298
|
+
for(let i=0;i<8;i++)writeFileSync(join(input,`item${i}.txt`),`content ${i}`);
|
|
299
|
+
const pack=collector();
|
|
300
|
+
archive({op:'compress',paths:[input],dest:join(dir,'pack.'+format),format},pack.send);
|
|
301
|
+
const packed=await until(pack.lines,l=>l.t==='archivedone');expect(packed.ok).toBe(true);
|
|
302
|
+
const result=collector();
|
|
303
|
+
archive({op:'extract',path:packed.path,dest,here:true,confirmed:false},result.send);
|
|
304
|
+
expect((await until(result.lines,l=>l.t==='archivedone')).ok).toBe(true);
|
|
305
|
+
expect(readdirSync(dest)).toEqual(['one-folder']);
|
|
306
|
+
expect(readdirSync(join(dest,'one-folder')).length).toBe(8);
|
|
307
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {readFileSync} from 'node:fs';
|
|
3
|
+
|
|
4
|
+
function loadFunction(file,name,scope={}) {
|
|
5
|
+
const source=readFileSync(new URL(file,import.meta.url),'utf8');
|
|
6
|
+
const match=source.match(new RegExp('^( *)function '+name+'\\(path\\) \\{([\\s\\S]*?)^\\1\\}', 'm'));
|
|
7
|
+
if(!match)throw Error('Missing '+name);
|
|
8
|
+
return new Function(...Object.keys(scope),`return (${match[0].trim()})`)(...Object.values(scope));
|
|
9
|
+
}
|
|
10
|
+
const format=loadFunction('../ui/js/Format.js','fileUri');
|
|
11
|
+
const boot=loadFunction('../ui/boot/shell.qml','fileUrl');
|
|
12
|
+
const drag=loadFunction('../ui/js/Drag.js','uriFor',{Format:{fileUri:format}});
|
|
13
|
+
const cases=[
|
|
14
|
+
['C:\\Users\\wxk\\AppData\\Local\\Temp\\flea\\ui\\boot/../WindowBody.qml','file:///C:/Users/wxk/AppData/Local/Temp/flea/ui/boot/../WindowBody.qml'],
|
|
15
|
+
['C:/Users/a b/中文#?%.qml','file:///C:/Users/a%20b/%E4%B8%AD%E6%96%87%23%3F%25.qml'],
|
|
16
|
+
['\\\\server\\share\\a b.png','file://server/share/a%20b.png'],
|
|
17
|
+
['/home/a b/#?%.png','file:///home/a%20b/%23%3F%25.png'],
|
|
18
|
+
['/home/a\\b.png','file:///home/a%5Cb.png'],
|
|
19
|
+
];
|
|
20
|
+
for(const [name,convert] of [['boot',boot],['preview',format],['drag',drag]]) {
|
|
21
|
+
test(name+' file URLs preserve Windows drives, UNC and POSIX path bytes',()=>{
|
|
22
|
+
for(const [path,url] of cases){expect(convert(path)).toBe(url);expect(new URL(convert(path)).protocol).toBe('file:');}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// bun test/manual-archive-here-ui.mjs (requires the sibling buninu-browser checkout).
|
|
2
|
+
import {mkdtempSync,mkdirSync,rmSync,existsSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
const repo=Bun.fileURLToPath(new URL('../',import.meta.url));
|
|
7
|
+
const dir=mkdtempSync(join(tmpdir(),'flea-terminal-ui-'));
|
|
8
|
+
process.env.XDG_DATA_HOME=join(dir,'data');process.env.XDG_CONFIG_HOME=join(dir,'config');process.env.XDG_CACHE_HOME=join(dir,'cache');process.env.FLEA_PATH=dir;process.env.BUNINU_BUN=process.execPath;
|
|
9
|
+
for(const p of ['data','config','cache'])mkdirSync(join(dir,p));
|
|
10
|
+
await Bun.write(join(dir,'notes.txt'),'line of preview text\n'.repeat(200));
|
|
11
|
+
const {createBrowser}=await import('../../buninu-browser/lib/headless-shell.js');
|
|
12
|
+
const file=join(repo,'ui/boot/.terminal-check-'+process.pid+'.qml');
|
|
13
|
+
await Bun.write(file,`import QtQuick\nimport Quickshell\nimport ".." as Flea\nimport "../js/Tabs.js" as Tabs\nFloatingWindow { id: win; width:900; height:600; Loader {anchors.fill:parent; source:"../WindowBody.qml";onLoaded:{item.host=win;globalThis.testTabs=Tabs;globalThis.testBody=item;globalThis.terminalService=Flea.TerminalService}} }`);
|
|
14
|
+
const browser=await createBrowser({spareRenderer:false});
|
|
15
|
+
const run=s=>browser.context.evaluate(s);
|
|
16
|
+
const until=async(s)=>{for(let i=0;i<200;i++){if(await run(s))return;await Bun.sleep(100);}throw Error('UI timeout '+s+' '+await run('document.body.innerText.slice(-2500)'));};
|
|
17
|
+
try {
|
|
18
|
+
const {archive}=await import('../src/archiveops.js');
|
|
19
|
+
const source=join(dir,'source');mkdirSync(source);
|
|
20
|
+
for(let i=0;i<8;i++)await Bun.write(join(source,`file${i}.txt`),'test');
|
|
21
|
+
await new Promise((resolve,reject)=>archive({op:'compress',paths:[source],dest:join(dir,'sample.zip'),format:'zip'},m=>{if(m.t==='archivedone')m.ok?resolve():reject(Error(m.err))}));
|
|
22
|
+
await browser.context.navigate(Bun.pathToFileURL(file).href);
|
|
23
|
+
await until('!!globalThis.testBody && !testBody.currentPane.listInFlight');
|
|
24
|
+
await run('for(let i=0;i<testBody.currentPane.total;i++)if(testBody.currentPane.rowFor(i)?.n==="sample.zip")testBody.currentPane.selectOnly(i,0)');
|
|
25
|
+
await until('testBody.currentPane.previewColumnItem?.meta?.topEntries===1');
|
|
26
|
+
assert.equal(await run('testBody.currentPane.previewColumnItem.meta.entries'),9);
|
|
27
|
+
console.log('PASS right preview carries 9 members / 1 top-level item');
|
|
28
|
+
await run('testBody.currentPane.act("preview")');
|
|
29
|
+
await until('testBody.currentPane.quickLook().archiveMeta?.topEntries===1');
|
|
30
|
+
assert.equal(await run('testBody.currentPane.quickLook().archiveMeta.entries'),9);
|
|
31
|
+
console.log('PASS Quick Look carries 9 members / 1 top-level item');
|
|
32
|
+
const button=await run('(()=>{const e=[...document.querySelectorAll("[data-qml-type=Text]")].filter(e=>e.textContent==="Extract here"&&e.getBoundingClientRect().width>0).at(-1);const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
|
|
33
|
+
await browser.context.click(button.x,button.y);
|
|
34
|
+
for(let i=0;i<100&&!existsSync(join(dir,'source copy'));i++)await Bun.sleep(100);
|
|
35
|
+
assert(existsSync(join(dir,'source copy')),'Extract here should run without confirmation');
|
|
36
|
+
console.log('PASS clicking Quick Look Extract here extracts without a confirmation dialog');
|
|
37
|
+
await run('testBody.currentPane.quickLook().close()');
|
|
38
|
+
await until('!testBody.currentPane.listInFlight');
|
|
39
|
+
await run('for(let i=0;i<testBody.currentPane.total;i++)if(testBody.currentPane.rowFor(i)?.n==="sample.zip")testBody.currentPane.selectOnly(i,0)');
|
|
40
|
+
await until('testBody.currentPane.previewColumnItem?.meta?.topEntries===1');
|
|
41
|
+
const right=await run('(()=>{const e=[...document.querySelectorAll("[data-qml-type=Text]")].find(e=>e.textContent==="Extract here"&&e.getBoundingClientRect().width>0);const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
|
|
42
|
+
await browser.context.click(right.x,right.y);
|
|
43
|
+
for(let i=0;i<100&&!existsSync(join(dir,'source copy 2'));i++)await Bun.sleep(100);
|
|
44
|
+
assert(existsSync(join(dir,'source copy 2')),'right preview Extract here should run without confirmation');
|
|
45
|
+
console.log('PASS clicking right preview Extract here extracts without confirmation');
|
|
46
|
+
|
|
47
|
+
}finally{await browser.close();rmSync(file,{force:true});rmSync(dir,{recursive:true,force:true});}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// bun test/manual-terminal-ui.mjs (requires the sibling buninu-browser checkout).
|
|
2
|
+
import {mkdtempSync,mkdirSync,rmSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
const repo=Bun.fileURLToPath(new URL('../',import.meta.url));
|
|
7
|
+
const dir=mkdtempSync(join(tmpdir(),'flea-terminal-ui-'));
|
|
8
|
+
process.env.XDG_DATA_HOME=join(dir,'data');process.env.XDG_CONFIG_HOME=join(dir,'config');process.env.XDG_CACHE_HOME=join(dir,'cache');process.env.FLEA_PATH=dir;process.env.BUNINU_BUN=process.execPath;
|
|
9
|
+
for(const p of ['data','config','cache'])mkdirSync(join(dir,p));
|
|
10
|
+
await Bun.write(join(dir,'notes.txt'),'line of preview text\n'.repeat(200));
|
|
11
|
+
const {createBrowser}=await import('../../buninu-browser/lib/headless-shell.js');
|
|
12
|
+
const file=join(repo,'ui/boot/.terminal-check-'+process.pid+'.qml');
|
|
13
|
+
await Bun.write(file,`import QtQuick\nimport Quickshell\nimport ".." as Flea\nimport "../js/Tabs.js" as Tabs\nFloatingWindow { id: win; width:900; height:600; Loader {anchors.fill:parent; source:"../WindowBody.qml";onLoaded:{item.host=win;globalThis.testTabs=Tabs;globalThis.testBody=item;globalThis.terminalService=Flea.TerminalService}} }`);
|
|
14
|
+
const browser=await createBrowser({spareRenderer:false});
|
|
15
|
+
const run=s=>browser.context.evaluate(s);
|
|
16
|
+
const until=async(s)=>{for(let i=0;i<200;i++){if(await run(s))return;await Bun.sleep(100);}throw Error('UI timeout '+s+' '+await run('document.body.innerText.slice(-2500)'));};
|
|
17
|
+
try {
|
|
18
|
+
await browser.context.navigate(Bun.pathToFileURL(file).href);
|
|
19
|
+
await until('!!globalThis.testBody && !testBody.currentPane.listInFlight');
|
|
20
|
+
|
|
21
|
+
await run('testBody.currentPane.act("newFolder")');
|
|
22
|
+
await until('testBody.currentPane.renamingIndex>=0 && document.activeElement.tagName==="INPUT"');
|
|
23
|
+
await browser.context.type('Created Folder');
|
|
24
|
+
await browser.context.press('Enter');
|
|
25
|
+
await until('testBody.currentPane.renamingIndex<0 && !testBody.currentPane.listInFlight');
|
|
26
|
+
await until('testBody.currentPane.activeFocus');
|
|
27
|
+
await Bun.sleep(300);
|
|
28
|
+
const before=await run('testBody.currentPane.cursorIndex');
|
|
29
|
+
await browser.context.press(before>0?'ArrowUp':'ArrowDown');
|
|
30
|
+
assert.equal(await run('testBody.currentPane.cursorIndex'),before>0?before-1:before+1);
|
|
31
|
+
console.log('PASS mkdir + rename + ArrowDown');
|
|
32
|
+
await run('document.activeElement.blur()');
|
|
33
|
+
const pos=await run('(()=>{const r=__qmlRuntime.element(testBody.currentPane.visibleItemFor(Array.from({length:testBody.currentPane.total},(_,i)=>i).find(i=>testBody.currentPane.rowFor(i)?.n==="notes.txt"))).getBoundingClientRect();return {x:r.x+40,y:r.y+r.height/2}})()');
|
|
34
|
+
await browser.context.click(pos.x,pos.y);
|
|
35
|
+
await until('testBody.currentPane.activeFocus');
|
|
36
|
+
const clicked=await run('testBody.currentPane.cursorIndex');
|
|
37
|
+
await browser.context.press('ArrowUp');
|
|
38
|
+
assert.equal(await run('testBody.currentPane.cursorIndex'),clicked-1);
|
|
39
|
+
console.log('PASS clicking row recovers list focus from BODY');
|
|
40
|
+
// Empty list space also takes focus, independently of a row TapHandler.
|
|
41
|
+
const point=async expr=>run(`(()=>{const r=__qmlRuntime.element(${expr}).getBoundingClientRect();return {x:r.x+r.width/2,y:r.bottom-15}})()`);
|
|
42
|
+
await run('document.activeElement.blur()');
|
|
43
|
+
const ground=await point('testBody.currentPane.listArea');
|
|
44
|
+
await browser.context.click(ground.x,ground.y);
|
|
45
|
+
await until('testBody.currentPane.listArea.activeFocus');
|
|
46
|
+
console.log('PASS empty listing ground takes focus');
|
|
47
|
+
await run('document.activeElement.blur()');
|
|
48
|
+
const rail=await point('testBody.currentPane.sidebar');
|
|
49
|
+
await browser.context.click(rail.x,rail.y);
|
|
50
|
+
await until('testBody.currentPane.sidebar.activeFocus');
|
|
51
|
+
const railIndex=await run('testBody.currentPane.sidebar.cursorIndex');
|
|
52
|
+
await browser.context.press('ArrowDown');
|
|
53
|
+
assert.equal(await run('testBody.currentPane.sidebar.cursorIndex'),railIndex+1);
|
|
54
|
+
console.log('PASS Places empty ground takes focus and arrow keys');
|
|
55
|
+
await run('testBody.currentPane.focusView="list";for(let i=0;i<testBody.currentPane.total;i++)if(testBody.currentPane.rowFor(i)?.n==="notes.txt")testBody.currentPane.selectOnly(i,0)');
|
|
56
|
+
await until('!!testBody.currentPane.previewColumnItem?.visible');
|
|
57
|
+
const preview=await point('testBody.currentPane.previewColumnItem');
|
|
58
|
+
await run('document.activeElement.blur()');
|
|
59
|
+
await browser.context.click(preview.x,preview.y);
|
|
60
|
+
await until('testBody.currentPane.previewColumnItem.activeFocus');
|
|
61
|
+
console.log('PASS preview ground takes keyboard focus');
|
|
62
|
+
|
|
63
|
+
}finally{await browser.close();rmSync(file,{force:true});rmSync(dir,{recursive:true,force:true});}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// bun test/manual-terminal-ui.mjs (requires the sibling buninu-browser checkout).
|
|
2
|
+
import {mkdtempSync,mkdirSync,rmSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
const repo=Bun.fileURLToPath(new URL('../',import.meta.url));
|
|
7
|
+
const dir=mkdtempSync(join(tmpdir(),'flea-terminal-ui-'));
|
|
8
|
+
process.env.XDG_DATA_HOME=join(dir,'data');process.env.XDG_CONFIG_HOME=join(dir,'config');process.env.XDG_CACHE_HOME=join(dir,'cache');process.env.FLEA_PATH=dir;process.env.BUNINU_BUN=process.execPath;
|
|
9
|
+
for(const p of ['data','config','cache'])mkdirSync(join(dir,p));
|
|
10
|
+
const {createBrowser}=await import('../../buninu-browser/lib/headless-shell.js');
|
|
11
|
+
const file=join(repo,'ui/boot/.terminal-check-'+process.pid+'.qml');
|
|
12
|
+
await Bun.write(file,`import QtQuick\nimport Quickshell\nimport ".." as Flea\nFloatingWindow { id: win; width:900; height:600; Loader {anchors.fill:parent; source:"../WindowBody.qml";onLoaded:{item.host=win;globalThis.testBody=item;globalThis.terminalService=Flea.TerminalService}} }`);
|
|
13
|
+
const browser=await createBrowser({spareRenderer:false});
|
|
14
|
+
const run=s=>browser.context.evaluate(s);
|
|
15
|
+
const until=async(s)=>{for(let i=0;i<200;i++){if(await run(s))return;await Bun.sleep(100);}throw Error('UI timeout '+s+' '+await run('document.body.innerText.slice(-2500)'));};
|
|
16
|
+
try {
|
|
17
|
+
await browser.context.navigate(Bun.pathToFileURL(file).href);
|
|
18
|
+
await until('!!globalThis.testBody && !testBody.currentPane.listInFlight');
|
|
19
|
+
await run('testBody.currentPane.openTerminal()');
|
|
20
|
+
await until('!!testBody.currentPane.terminalId');
|
|
21
|
+
await until('Object.values(terminalService.tabs).some(t=>t.url?.includes("reconnect-token"))');
|
|
22
|
+
const one=await run('Object.values(terminalService.tabs)[0].url');
|
|
23
|
+
await run('testBody.currentPane.openTerminal()');
|
|
24
|
+
await until('Object.values(terminalService.tabs).length===2 && Object.values(terminalService.tabs).every(t=>t.url?.includes("reconnect-token"))');
|
|
25
|
+
const urls=await run('Object.values(terminalService.tabs).map(t=>t.url)');
|
|
26
|
+
assert.equal(new URL(urls[0]).host,new URL(urls[1]).host);assert.notEqual(urls[0],urls[1]);
|
|
27
|
+
assert.equal(await run('testBody.currentPane.tabs.items.length'),3);
|
|
28
|
+
console.log('PASS two terminal tabs, shared listener, distinct sessions',new URL(one).port);
|
|
29
|
+
await run('for (const id of Object.keys(terminalService.tabs)) terminalService.release(id)');
|
|
30
|
+
await until('!terminalService.stopping && !terminalService.process.running');
|
|
31
|
+
let closed=false;try{await fetch(one)}catch{closed=true}assert(closed);
|
|
32
|
+
console.log('PASS final terminal closes shared listener');
|
|
33
|
+
await run('terminalService.acquire(testBody.currentPane.path)');
|
|
34
|
+
await until('Object.values(terminalService.tabs).some(t=>t.url)');
|
|
35
|
+
const restarted=await run('Object.values(terminalService.tabs)[0].url');
|
|
36
|
+
assert.equal((await fetch(restarted)).status,200);
|
|
37
|
+
await run('for (const id of Object.keys(terminalService.tabs)) terminalService.release(id); terminalService.acquire(testBody.currentPane.path)');
|
|
38
|
+
await until('!terminalService.stopping && Object.values(terminalService.tabs).some(t=>t.url)');
|
|
39
|
+
console.log('PASS reopen during shutdown starts a fresh listener');
|
|
40
|
+
await run('for (const id of Object.keys(terminalService.tabs)) terminalService.release(id)');
|
|
41
|
+
await until('!terminalService.stopping && !terminalService.process.running');
|
|
42
|
+
}finally{await browser.close();rmSync(file,{force:true});rmSync(dir,{recursive:true,force:true});}
|