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.
Files changed (49) hide show
  1. package/README.md +35 -2
  2. package/package.json +2 -2
  3. package/src/archive.js +5 -0
  4. package/src/archiveops.js +36 -5
  5. package/src/menu.js +2 -2
  6. package/src/meta.js +9 -3
  7. package/src/terminal-server.js +95 -0
  8. package/src/terminal-shell.js +26 -0
  9. package/src/trashbrowse.js +1 -0
  10. package/test/delete-trash.test.js +2 -2
  11. package/test/file-ops.test.js +66 -0
  12. package/test/file-url.test.js +24 -0
  13. package/test/manual-archive-here-ui.mjs +47 -0
  14. package/test/manual-folder-focus.mjs +63 -0
  15. package/test/manual-terminal-lifecycle.mjs +42 -0
  16. package/test/manual-terminal-navigation.mjs +48 -0
  17. package/test/manual-terminal-ui.mjs +47 -0
  18. package/test/trashbrowse.test.js +1 -1
  19. package/ui/ArchiveCards.qml +28 -1
  20. package/ui/ArchiveHereConfirm.qml +127 -0
  21. package/ui/Backend.qml +5 -1
  22. package/ui/ColumnPane.qml +2 -0
  23. package/ui/Opener.qml +16 -36
  24. package/ui/Pane.qml +25 -1
  25. package/ui/PaneRail.qml +32 -3
  26. package/ui/PaneWire.qml +2 -6
  27. package/ui/Preview.qml +3 -2
  28. package/ui/PreviewArchive.qml +26 -1
  29. package/ui/PreviewColumn.qml +2 -0
  30. package/ui/QuickLookArchive.qml +3 -0
  31. package/ui/SelectionPreview.qml +1 -1
  32. package/ui/Sidebar.qml +5 -9
  33. package/ui/TabBar.qml +3 -3
  34. package/ui/TerminalService.qml +69 -0
  35. package/ui/TerminalTab.qml +48 -0
  36. package/ui/TerminalTabs.qml +27 -0
  37. package/ui/WindowBody.qml +2 -0
  38. package/ui/boot/shell.qml +10 -1
  39. package/ui/js/Archive.js +1 -1
  40. package/ui/js/Drag.js +1 -5
  41. package/ui/js/Focus.js +2 -0
  42. package/ui/js/Format.js +10 -1
  43. package/ui/js/Keymap.js +2 -2
  44. package/ui/js/Messages.js +1 -1
  45. package/ui/js/Nav.js +1 -1
  46. package/ui/js/Tabs.js +15 -0
  47. package/ui/js/Tap.js +6 -0
  48. package/ui/js/Trash.js +4 -1
  49. package/ui/qmldir +2 -0
@@ -0,0 +1,48 @@
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\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}} }`);
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
+
24
+
25
+
26
+ await until('!!document.querySelector("canvas")');
27
+ await browser.context.click(400,100);
28
+ await run('testTabs.selectAt(testBody.currentPane,0)');
29
+ await Bun.sleep(100);
30
+ console.log('ordinary focus',await run('({focus:testBody.currentPane.activeFocus,active:document.activeElement.tagName,id:testBody.currentPane.terminalId})'));
31
+ const cursor=await run('testBody.currentPane.cursorIndex');
32
+ await browser.context.press('ArrowDown');
33
+ assert.equal(await run('testBody.currentPane.cursorIndex'),cursor+1,'ArrowDown must reach the ordinary list');
34
+ await browser.context.press('ArrowUp');
35
+ assert.equal(await run('testBody.currentPane.cursorIndex'),cursor);
36
+ await browser.context.press('t');
37
+ assert.equal(await run('testBody.currentPane.tabs.items.length'),3,'ordinary t must work after terminal switch');
38
+ await browser.context.press('t',{modifiers:2,code:'KeyT'});
39
+ assert.equal(await run('testBody.currentPane.tabs.items.length'),4,'Ctrl+T must open another terminal');
40
+ console.log('PASS terminal -> ordinary -> arrows -> t -> Ctrl+T');
41
+ await run('testTabs.closeAt(testBody.currentPane,testBody.currentPane.tabs.index)');
42
+ await Bun.sleep(100);
43
+ assert.equal(await run('testBody.currentPane.activeFocus'),true,'closing terminal returns focus');
44
+ await browser.context.press('t',{modifiers:2,code:'KeyT'});
45
+ assert.equal(await run('testBody.currentPane.tabs.items.length'),4);
46
+ console.log('PASS close terminal -> ordinary -> Ctrl+T');
47
+
48
+ }finally{await browser.close();rmSync(file,{force:true});rmSync(dir,{recursive:true,force:true});}
@@ -0,0 +1,47 @@
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
+ // Child frame text confirms scripts load; input/PTY tested separately by the wire test.
30
+ await Bun.sleep(12000);
31
+ const target=new URL(urls[1]);
32
+ const monitor=new WebSocket(target.origin+target.pathname.replace(/\/$/,'/ws').replace(/^http/,'ws'),'webtty');
33
+ let output='';
34
+ monitor.onopen=()=>monitor.send(JSON.stringify({AuthToken:'',ReadOnly:true,ReconnectToken:target.searchParams.get('reconnect-token')}));
35
+ monitor.onmessage=e=>{const data=String(e.data);if(data[0]==='1')output+=Buffer.from(data.slice(1),'base64').toString();};
36
+ await Bun.sleep(200);
37
+ assert.equal(await run('document.activeElement.tagName'),'TEXTAREA','new terminal must own keyboard without a click');
38
+ for(const key of "printf '%s%s\\n' FLEA_ QTWEB_OK")await browser.context.press(key);
39
+ await browser.context.press('Enter');
40
+ for(let i=0;i<100&&!output.includes('FLEA_QTWEB_OK');i++)await Bun.sleep(100);
41
+ monitor.close();
42
+ assert(output.includes('FLEA_QTWEB_OK'),'Keyboard did not reach terminal: '+output);
43
+ console.log('PASS keyboard traverses QML WebEngineView to its PTY');
44
+ await Bun.write('/tmp/flea-terminal-tabs.png',Buffer.from(await browser.context.screenshot({format:'png'}),'base64'));
45
+ console.log('canvas',await run('[...document.querySelectorAll("canvas")].map(c=>({w:c.width,h:c.height,box:c.getBoundingClientRect().toJSON()}))'));
46
+ console.log('frames',await run('[...document.querySelectorAll("iframe")].map(f=>({src:f.src,text:f.contentDocument?.body?.innerText?.slice(-500)}))'));
47
+ }finally{await browser.close();rmSync(file,{force:true});rmSync(dir,{recursive:true,force:true});}
@@ -14,7 +14,7 @@ test('Trash list, restore, collisions and single-use deletion',()=>{
14
14
  writeFileSync(a.original,'new');expect(call('restore',{uris:[a.uri],identities:[a.identity]}).failed).toBe(1);
15
15
  expect(readFileSync(a.original,'utf8')).toBe('new');rmSync(a.original);
16
16
  expect(call('restore',{uris:[a.uri],identities:[a.identity]}).done).toBe(1);expect(readFileSync(a.original,'utf8')).toBe('payload');
17
- const b=item('b');const {token}=call('prepare',{uris:[b.uri],identities:[b.identity]});
17
+ const b=item('b');const prepared=call('prepare',{uris:[b.uri],identities:[b.identity]});expect(prepared.name).toBe('b');const {token}=prepared;
18
18
  expect(call('check',{token}).valid).toBe(true);expect(call('delete',{token}).done).toBe(1);expect(call('delete',{token}).ok).toBe(false);
19
19
  });
20
20
  test('cancel and failed replacement reviews invalidate deletion tokens',()=>{
@@ -18,7 +18,7 @@ Item {
18
18
 
19
19
  // One card at a time: a second request is answered with a sentence and never dropped silently.
20
20
  function cardBusy(holder) {
21
- if (!compressDialog.opened && !extractDialog.opened)
21
+ if (!compressDialog.opened && !extractDialog.opened && !hereDialog.opened)
22
22
  return false
23
23
  holder.message(ArchiveCard.CARD_BUSY, true)
24
24
  return true
@@ -38,6 +38,33 @@ Item {
38
38
  extractDialog.item.open(source, holder, err)
39
39
  }
40
40
 
41
+ function askExtractHere(holder, path, entries) {
42
+ if (cardBusy(holder)) return
43
+ var dest = path.substring(0, path.lastIndexOf("/") + 1)
44
+ if (entries >= 0 && entries <= 5) {
45
+ holder.backend.extractHere(path, dest, false)
46
+ return
47
+ }
48
+ hereDialog.active = true
49
+ hereDialog.item.open({ path: path, dest: dest, entries: entries, holder: holder, focusItem: holder.preview && holder.preview.active ? holder.preview : holder })
50
+ }
51
+
52
+ Loader {
53
+ id: hereDialog
54
+ anchors.fill: parent
55
+ active: false
56
+ source: "ArchiveHereConfirm.qml"
57
+ readonly property bool opened: item !== null && item.opened
58
+ }
59
+ Connections {
60
+ target: hereDialog.item
61
+ function onConfirmed(source) {
62
+ source.holder.backend.extractHere(source.path, source.dest, true)
63
+ source.focusItem.forceActiveFocus()
64
+ }
65
+ function onCancelled() { if (hereDialog.item.snapshot.focusItem) hereDialog.item.snapshot.focusItem.forceActiveFocus() }
66
+ }
67
+
41
68
  anchors.fill: parent
42
69
 
43
70
  Loader {
@@ -0,0 +1,127 @@
1
+ import QtQuick
2
+ import qs.Commons
3
+ import "." as Flea
4
+
5
+ // Bulk extraction is deliberate: Enter defaults to Cancel, as in permanent deletion.
6
+ FocusScope {
7
+ id: root
8
+ anchors.fill: parent
9
+ visible: opened || opacity > 0
10
+ // Closed means no input, even while the fade-out is still drawn.
11
+ enabled: opened
12
+ opacity: 0
13
+ property bool opened: false
14
+ property var snapshot: ({})
15
+ property bool destructiveFocus: false
16
+ readonly property real referenceScale: Theme.font.bodySmall / 13
17
+ readonly property real cardPadding: Math.round(16 * referenceScale)
18
+ readonly property real cardBottomPadding: Theme.spacing.rowPaddingX
19
+ signal confirmed(var source)
20
+ signal cancelled()
21
+ readonly property var cardItem: card
22
+ readonly property var cancelItem: cancelButton
23
+ readonly property var dangerItem: dangerButton
24
+ readonly property string titleText: title.text
25
+ readonly property string bodyText: bodyLabel.text
26
+ function open(value) { snapshot = value; destructiveFocus = false; body.contentY = 0; opened = true; forceActiveFocus() }
27
+ function close() { opened = false }
28
+ function cancel() { close(); cancelled() }
29
+ function activate() {
30
+ if (!destructiveFocus) { cancel(); return }
31
+ var source = snapshot
32
+ close()
33
+ confirmed(source)
34
+ }
35
+ Flea.Reveal { target: root; shown: root.opened; lift: card }
36
+ Keys.onPressed: function(event) {
37
+ if (event.key === Qt.Key_Escape) root.cancel()
38
+ else if (event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)) { event.accepted = true; return }
39
+ else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) root.destructiveFocus = !root.destructiveFocus
40
+ else if (event.key === Qt.Key_L || event.key === Qt.Key_Right) root.destructiveFocus = true
41
+ else if (event.key === Qt.Key_H || event.key === Qt.Key_Left) root.destructiveFocus = false
42
+ else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) root.activate()
43
+ if (root.opened) body.reveal(root.destructiveFocus ? dangerButton : cancelButton)
44
+ event.accepted = true
45
+ }
46
+ Rectangle {
47
+ anchors.fill: parent
48
+ color: Theme.color.background
49
+ opacity: 0.5
50
+ MouseArea {
51
+ anchors.fill: parent
52
+ hoverEnabled: true
53
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
54
+ onClicked: root.cancel()
55
+ onWheel: function(wheel) { wheel.accepted = true }
56
+ }
57
+ }
58
+ Rectangle {
59
+ id: card
60
+ x: Theme.cardOrigin(root.width, width)
61
+ y: Theme.cardOrigin(root.height, height)
62
+ // TrashSidebar's 340px content width excludes its two 16px paddings and hairlines.
63
+ width: Theme.cardSpan(Math.round(340 * root.referenceScale * Theme.dialogWidthRatio) + 2 * root.cardPadding + 2 * Theme.spacing.hairline, root.width - 2 * Theme.spacing.gap)
64
+ height: Theme.cardSpan(body.wanted + root.cardPadding + root.cardBottomPadding + 2 * Theme.spacing.hairline, root.height - 2 * Theme.spacing.gap)
65
+ color: Theme.color.surface
66
+ border.color: Theme.color.mutedFrame
67
+ border.width: Theme.spacing.hairline
68
+ radius: Style.cornerRadius
69
+ MouseArea {
70
+ anchors.fill: parent
71
+ hoverEnabled: true
72
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
73
+ onWheel: function(wheel) { wheel.accepted = true }
74
+ }
75
+ Flea.CardFrame {
76
+ id: body
77
+ bleed: Theme.focusClearance
78
+ anchors.fill: parent
79
+ anchors.leftMargin: root.cardPadding + Theme.spacing.hairline - Theme.focusClearance
80
+ anchors.rightMargin: root.cardPadding + Theme.spacing.hairline - Theme.focusClearance
81
+ anchors.topMargin: root.cardPadding + Theme.spacing.hairline - Theme.focusClearance
82
+ anchors.bottomMargin: root.cardBottomPadding + Theme.spacing.hairline - Theme.focusClearance
83
+ title: Row {
84
+ width: parent.width
85
+ spacing: Theme.spacing.gap
86
+ Flea.Glyph { id: alertMark; width: Theme.font.bodySmall * 1.3; height: title.height; name: "alert"; color: Theme.color.error }
87
+ Text {
88
+ id: title
89
+ width: parent.width - alertMark.width - parent.spacing
90
+ text: "Extract here?"
91
+ textFormat: Text.PlainText
92
+ wrapMode: Text.Wrap
93
+ color: Theme.color.foreground
94
+ font { family: Theme.font.family; pixelSize: Theme.font.body; bold: true }
95
+ }
96
+ }
97
+ buttons: Flow {
98
+ width: Math.min(parent.width, cancelButton.implicitWidth + dangerButton.implicitWidth + spacing)
99
+ anchors.right: parent.right
100
+ spacing: Theme.spacing.gap
101
+ Flea.DialogButton {
102
+ id: cancelButton
103
+ label: "Cancel"
104
+ primary: true
105
+ focused: !root.destructiveFocus
106
+ onActivated: root.cancel()
107
+ }
108
+ Flea.DialogButton {
109
+ id: dangerButton
110
+ label: "Extract here"
111
+ destructive: true
112
+ focused: root.destructiveFocus
113
+ onActivated: { root.destructiveFocus = true; root.activate() }
114
+ }
115
+ }
116
+ Text {
117
+ id: bodyLabel
118
+ width: parent.width
119
+ text: (root.snapshot.entries >= 0 ? root.snapshot.entries + " top-level items" : "This archive") + " will be extracted into:\n" + root.snapshot.dest + "\n\nExisting names will get a copy suffix. Continue?"
120
+ textFormat: Text.PlainText
121
+ wrapMode: Text.Wrap
122
+ color: Theme.color.foreground
123
+ font { family: Theme.font.family; pixelSize: Theme.font.body }
124
+ }
125
+ }
126
+ }
127
+ }
package/ui/Backend.qml CHANGED
@@ -121,7 +121,7 @@ Item {
121
121
  property int compareToken: 0
122
122
  // One counter numbers every PDF fetch, so two viewers never share an id.
123
123
  property int pdfCopySeq: 0
124
- signal meta(int row, int w, int h, int orient, real durationMs, int sampleRate, int entries, real unpacked, bool archiveFailed, var names, real lines, bool partial, bool linesFailed, string target, bool targetDir, string owner)
124
+ signal meta(int row, int w, int h, int orient, real durationMs, int sampleRate, int entries, real unpacked, bool archiveFailed, var names, real lines, bool partial, bool linesFailed, string target, bool targetDir, string owner, int topEntries)
125
125
  signal shebang(string path, bool hasShebang, int id)
126
126
  // A PDF fetched into a session-private copy under a deadline; err names the refusal, stalled is the wait that ran out.
127
127
  signal pdfCopied(int id, string path, string err, bool stalled)
@@ -566,6 +566,10 @@ Item {
566
566
  password: password, encryptNames: encryptNames === true, menuId: menuId || 0 })
567
567
  }
568
568
 
569
+ function extractHere(path, dest, confirmed) {
570
+ root.send({ c: "archive", op: "extract", path: path, dest: dest, here: true, confirmed: confirmed === true })
571
+ }
572
+
569
573
  function extract(path, dest, menuId) {
570
574
  root.send({ c: "archive", op: "extract", path: path, dest: dest, menuId: menuId || 0 })
571
575
  }
package/ui/ColumnPane.qml CHANGED
@@ -286,6 +286,8 @@ Item {
286
286
 
287
287
  ListView {
288
288
  id: view
289
+ // The shared pane owns selection and arrow navigation, including after rename.
290
+ keyNavigationEnabled: false
289
291
  anchors.top: columnHeader.bottom
290
292
  anchors.left: parent.left
291
293
  anchors.right: parent.right
package/ui/Opener.qml CHANGED
@@ -2,38 +2,24 @@ import Quickshell
2
2
  import Quickshell.Io
3
3
  import QtQuick
4
4
 
5
- // The one component that runs Flea's own opening modes, so the huge page corner has one owner; see AGENTS.md "Opening a file".
5
+ // Open files through xdg-open on PATH on every platform, including Windows.
6
6
  Item {
7
7
  id: root
8
8
 
9
9
  signal failed(string path)
10
- signal isDirectory(string path)
11
10
  signal terminalFailed(string path)
12
11
  // Raised where the two single-flight guards below drop a request, so a swallowed press says so.
13
12
  signal busy(string path)
14
13
  signal terminalBusy(string path)
15
- // flea --open answers 4 with the run card's own ask line on stdout, and 5 where no application claims the file.
16
- signal askRunRaw(string path, string text)
17
- signal askOpenWith(string path)
18
-
19
- // The status src/open.rs returns for a directory, which the caller navigates to instead.
20
- readonly property int isDirectoryStatus: 3
21
- // The two statuses src/runfile.rs added beside it: ask the card, or ask Open with.
22
- readonly property int askRunStatus: 4
23
- readonly property int askOpenWithStatus: 5
24
-
25
14
  property string current: ""
26
15
  // The terminal launch's own path: open() and openTerminal() run on separate
27
16
  // Processes guarded only against themselves, so sharing current let whichever
28
17
  // started second rewrite the path the first one's onExited still reports.
29
18
  property string terminalCurrent: ""
30
- // One open at a time: pending is the launch with no exit yet, and the stream arrives before the exit.
19
+ // One open at a time until xdg-open exits.
31
20
  property bool pending: false
32
- property string askText: ""
21
+ property int openSerial: 0
33
22
 
34
- // flea --open waits for gio open and not for the application it starts, and that wait is 11 to 15 ms
35
- // for an Exec= handler but 0.32 to 0.75 s for a DBusActivatable one, which is what this box's
36
- // twenty-five archive types default to, so this guard drops a second Enter for that long and says so.
37
23
  function open(path) {
38
24
  if (child.running || root.pending) {
39
25
  root.busy(path)
@@ -41,38 +27,32 @@ Item {
41
27
  }
42
28
  root.current = path
43
29
  root.pending = true
44
- root.askText = ""
45
- child.command = [Quickshell.env("FLEA_BIN") || "flea", "--open", path]
30
+ root.openSerial++
31
+ child.command = ["xdg-open", path]
46
32
  child.running = true
47
33
  }
48
34
 
49
35
  Process {
50
36
  id: child
51
- stdout: StdioCollector {
52
- waitForEnd: true
53
- onStreamFinished: root.askText = this.text
54
- }
55
-
56
- // An ask status without its line is a launch that died mid-answer, so it says the plain failure and opens no card.
37
+ // xdg-open's nonzero exit codes are failures, not Flea's private statuses.
57
38
  onExited: function (exitCode, exitStatus) {
58
39
  root.pending = false
59
- if (exitCode === 0)
60
- return
61
- if (exitCode === root.isDirectoryStatus)
62
- root.isDirectory(root.current)
63
- else if (exitCode === root.askRunStatus && root.askText !== "")
64
- root.askRunRaw(root.current, root.askText)
65
- else if (exitCode === root.askOpenWithStatus)
66
- root.askOpenWith(root.current)
67
- else
40
+ if (exitCode !== 0 || exitStatus !== 0)
68
41
  root.failed(root.current)
69
42
  }
70
43
  // A program that never started reports no exit, only running turning false.
71
44
  onRunningChanged: {
72
45
  if (child.running || !root.pending)
73
46
  return
74
- root.pending = false
75
- root.failed(root.current)
47
+ // running may change before onExited. Let that signal settle first;
48
+ // a deferred failure must not apply to a later open request.
49
+ var serial = root.openSerial
50
+ Qt.callLater(function () {
51
+ if (root.pending && !child.running && root.openSerial === serial) {
52
+ root.pending = false
53
+ root.failed(root.current)
54
+ }
55
+ })
76
56
  }
77
57
  }
78
58
 
package/ui/Pane.qml CHANGED
@@ -271,6 +271,7 @@ FocusScope {
271
271
  // Archives040: the password compress resolves through the cards host, which Focus reaches
272
272
  // through this signal so Focus takes no new first-frame import.
273
273
  signal compressPasswordRequested(var paths, int menuId)
274
+ signal extractHereRequested(string path, int entries)
274
275
  signal extractPasswordRequested(string path, string dest, string err)
275
276
  signal permissionsRequested(string path)
276
277
  // MenuAdditions040: the whole selection's paths for the multi-row card.
@@ -449,6 +450,18 @@ FocusScope {
449
450
  property var history: []
450
451
  property var forwardHistory: []
451
452
  property var tabs: null
453
+ property string terminalId: ""
454
+ onTerminalIdChanged: {
455
+ if (!terminalId) Qt.callLater(function() {
456
+ // A hidden WebEngineView no longer owns input. Wait until tab visibility
457
+ // settles, then return focus to the file list (not the old scope child).
458
+ if (!root.terminalId) {
459
+ root.focusView = Focus.LIST
460
+ root.listArea.forceActiveFocus()
461
+ }
462
+ })
463
+ }
464
+ readonly property var terminalHost: terminalLoader.item
452
465
  property int tabCountNotice: -1
453
466
  readonly property bool canGoBack: trashHost.opened || root.recentMode.length > 0 || root.history.length > 0
454
467
  readonly property bool canGoUp: root.path.length > 1 || root.recentMode.length > 0 || Nav.climbsFromListing(root)
@@ -851,7 +864,18 @@ FocusScope {
851
864
  function openFile(path) { wire.opener.open(path) }
852
865
 
853
866
  // A terminal in the caller's place or the directory being shown, through flea --terminal.
854
- function openTerminal(path) { wire.opener.openTerminal(path || root.path) }
867
+ function openTerminal(path) {
868
+ terminalLoader.active = true
869
+ Tabs.openTerminal(root, path || root.path)
870
+ }
871
+ Loader {
872
+ id: terminalLoader
873
+ active: false
874
+ anchors { top: parent.top; left: railHost.right; right: parent.right; bottom: parent.bottom }
875
+ z: 1
876
+ source: "TerminalTabs.qml"
877
+ onLoaded: item.pane = root
878
+ }
855
879
 
856
880
  function newWindow() { Quickshell.execDetached(Tabs.freshLaunch([Quickshell.env("FLEA_BIN") || "flea", root.path])) }
857
881
 
package/ui/PaneRail.qml CHANGED
@@ -33,12 +33,38 @@ Item {
33
33
  function focusPress(p) {
34
34
  var pane = root.pane
35
35
  if (p.x < 0 || p.y < 0 || p.x >= pane.width || p.y >= pane.height) return
36
+ // Preserve an input caret when the press is inside that very editor.
37
+ var focused = pane.Window.window ? pane.Window.window.activeFocusItem : null
38
+ if (focused && focused.cursorPosition !== undefined && focused.mapFromItem) {
39
+ var caret = focused.mapFromItem(pane, p.x, p.y)
40
+ if (caret.x >= 0 && caret.y >= 0 && caret.x < focused.width && caret.y < focused.height) return
41
+ }
36
42
  // The shown Sidebar can extend past its zero-width overlay host.
37
43
  var sidebar = root.item
38
44
  var r = sidebar ? sidebar.mapFromItem(pane, p.x, p.y) : null
39
45
  if (!root.hidden && r && r.x >= 0 && r.y >= 0 && r.x < sidebar.width && r.y < sidebar.height) {
40
- if (root.overlay) pane.railPane.focusView = Focus.RAIL
41
- } else { pane.focusView = Focus.LIST; pane.focusRequested() }
46
+ pane.railPane.focusView = Focus.RAIL
47
+ if (!sidebar.renameEditor()) sidebar.forceActiveFocus()
48
+ } else {
49
+ if (pane.terminalId) return
50
+ var preview = pane.previewColumnItem
51
+ if (preview && preview.visible) {
52
+ var at = preview.mapFromItem(pane, p.x, p.y)
53
+ if (at.x >= 0 && at.y >= 0 && at.x < preview.width && at.y < preview.height) {
54
+ preview.forceActiveFocus()
55
+ return
56
+ }
57
+ }
58
+ var list = pane.listArea
59
+ if (!list || !list.visible) return
60
+ var local = list.mapFromItem(pane, p.x, p.y)
61
+ if (local.x < 0 || local.y < 0 || local.x >= list.width || local.y >= list.height) return
62
+ // Keep a live rename's caret and click-away commit semantics.
63
+ if (pane.renameEditor()) return
64
+ pane.focusView = Focus.LIST
65
+ pane.focusRequested()
66
+ list.forceActiveFocus()
67
+ }
42
68
  }
43
69
 
44
70
  // Long enough that crossing the edge on the way somewhere else does not flash the rail, and that
@@ -161,7 +187,10 @@ Item {
161
187
  menu: root.pane.railPane.contextMenu()
162
188
  onRenameFinished: root.pane.railPane.listArea.forceActiveFocus()
163
189
  // The pane's own PointHandler never sees a press a row grabs, so an overlay rail claims the keyboard itself; a docked rail leaves it.
164
- onPressed: if (root.overlay) root.pane.railPane.focusView = Focus.RAIL
190
+ onPressed: {
191
+ root.pane.railPane.focusView = Focus.RAIL
192
+ if (!sidebar.renameEditor()) sidebar.forceActiveFocus()
193
+ }
165
194
  onReleasingChanged: root.holdRelease(sidebar.releasing)
166
195
  }
167
196
  // Disabled with the Sidebar, so an unload under the pointer clears it; a handler inside the Sidebar dies without reporting.
package/ui/PaneWire.qml CHANGED
@@ -79,14 +79,10 @@ Item {
79
79
  id: opener
80
80
  // A dropped request is the app being busy, not a failure, so it takes the plain role.
81
81
  onBusy: function (path) { pane.message("Still opening the last file; try again in a moment.", false) }
82
- // canonicalize proved the path before every failure src/open.rs and src/terminal.rs report under their one status, so neither sentence below names a cause.
83
- onFailed: function (path) { pane.message("No application on this system opened that file.", true) }
84
- onIsDirectory: function (path) { pane.open(path) }
82
+ onFailed: function (path) { pane.message("xdg-open could not open that file. Make sure xdg-open is available on PATH.", true) }
85
83
  onTerminalBusy: function (path) { pane.message("Still opening the last terminal; try again in a moment.", false) }
86
84
  onTerminalFailed: function (path) { pane.message("No terminal on this system opened that folder.", true) }
87
- // The run card's own answers: a program asks once on its raw ask line, and a file no application claims asks Open with.
88
- onAskRunRaw: function (path, text) { pane.askRunCard({ path: path, raw: text }) }
89
- onAskOpenWith: function (path) { pane.showOpenWith(path) }
85
+
90
86
  }
91
87
 
92
88
  Flea.ShareLink {
package/ui/Preview.qml CHANGED
@@ -641,9 +641,9 @@ Item {
641
641
 
642
642
  Connections {
643
643
  target: root.pane ? root.pane.backend : null
644
- function onMeta(row, w, h, orient, durationMs, sampleRate, entries, unpacked, archiveFailed, names, lines, partial, linesFailed, target, targetDir, owner) {
644
+ function onMeta(row, w, h, orient, durationMs, sampleRate, entries, unpacked, archiveFailed, names, lines, partial, linesFailed, target, targetDir, owner, topEntries) {
645
645
  if (root.isArchive && row === root.archiveRow)
646
- root.archiveMeta = { entries: entries, unpacked: unpacked, archiveFailed: archiveFailed, names: names }
646
+ root.archiveMeta = { topEntries: typeof topEntries === "number" ? topEntries : -1, entries: entries, unpacked: unpacked, archiveFailed: archiveFailed, names: names }
647
647
  if (root.isMedia && row === root.mediaRow)
648
648
  root.mediaRate = sampleRate
649
649
  if (root.isImage && !root.isRawFile && row === root.imageRow && root.imageRowShown()) {
@@ -916,6 +916,7 @@ Item {
916
916
  visible: root.isArchive && root.archiveMeta !== null && !root.archiveFailed
917
917
  path: root.path
918
918
  meta: root.archiveMeta
919
+ pane: root.pane
919
920
  }
920
921
 
921
922
  // Declined, or an archive whose index could not be read: a mark over the sentence, never a bare surface.
@@ -3,6 +3,7 @@ import qs.Commons
3
3
  import "." as Flea
4
4
  import "js/Facts.js" as Facts
5
5
  import "js/Format.js" as Format
6
+ import "js/Archive.js" as Archive
6
7
 
7
8
  // The Archive tile's frame: the entries the wire carried, as many as the frame has room for, and the
8
9
  // count it could not name. One row per line box, less the "+ N more" line, which always has to be
@@ -12,12 +13,36 @@ Item {
12
13
 
13
14
  // The meta answer for the archive row, or null before one has arrived.
14
15
  property var meta: null
16
+ property string path: ""
17
+ property var pane: null
18
+ readonly property bool extractable: pane !== null && /\.(zip|tar|tar\.gz|tgz|tar\.zst|tzst)$/i.test(path) && meta !== null && !meta.archiveFailed
19
+
20
+ Flow {
21
+ id: actions
22
+ width: parent.width
23
+ spacing: Theme.spacing.gap
24
+ visible: root.extractable
25
+ Flea.DialogButton {
26
+ label: "Extract to Folder"
27
+ tabStop: true
28
+ onActivated: {
29
+ var base = root.path.substring(0, root.path.lastIndexOf("/") + 1)
30
+ root.pane.backend.extract(root.path, base + Archive.extractDir(root.path.substring(base.length)), 0)
31
+ }
32
+ }
33
+ Flea.DialogButton {
34
+ label: "Extract here"
35
+ tabStop: true
36
+ onActivated: root.pane.extractHereRequested(root.path, root.meta && typeof root.meta.topEntries === "number" ? root.meta.topEntries : -1)
37
+ }
38
+ }
15
39
 
16
40
  readonly property real lineHeight: Math.round(Theme.font.caption * Theme.lineBoxRatio)
17
- readonly property int shown: Math.max(0, Math.floor(height / lineHeight) - 1)
41
+ readonly property int shown: Math.max(0, Math.floor((height - (actions.visible ? actions.height + Theme.spacing.gap : 0)) / lineHeight) - 1)
18
42
 
19
43
  Column {
20
44
  anchors.fill: parent
45
+ anchors.topMargin: actions.visible ? actions.height + Theme.spacing.gap : 0
21
46
  clip: true
22
47
  spacing: 0
23
48
 
@@ -327,6 +327,8 @@ Item {
327
327
  anchors.margins: Theme.spacing.gap
328
328
  visible: root.previewState === Facts.ARCHIVE && root.meta !== null
329
329
  meta: root.meta
330
+ path: root.path
331
+ pane: root.pane
330
332
  }
331
333
 
332
334
  Flea.LoadingState {
@@ -9,6 +9,7 @@ Column {
9
9
  spacing: Theme.spacing.gap
10
10
  property string path: ""
11
11
  property var meta: null
12
+ property var pane: null
12
13
 
13
14
  // corner: a filename is arbitrary text, so PlainText, the same rule every name on this surface follows.
14
15
  Text {
@@ -34,5 +35,7 @@ Column {
34
35
  width: parent.width
35
36
  height: parent.height - y
36
37
  meta: root.meta
38
+ path: root.path
39
+ pane: root.pane
37
40
  }
38
41
  }
@@ -360,7 +360,7 @@ Flea.PreviewColumn {
360
360
  || root.loadedIdentity !== root.identity(root.pane.rowFor(root.loadedIndex))) return
361
361
  root.pendingToken = 0
362
362
  root.meta = { w: message.w, h: message.h, durationMs: message.ms, sampleRate: message.rate,
363
- entries: message.entries, unpacked: message.unpacked, archiveFailed: message.afailed,
363
+ topEntries: typeof message.topEntries === "number" ? message.topEntries : -1, entries: message.entries, unpacked: message.unpacked, archiveFailed: message.afailed,
364
364
  names: message.names, lines: message.lines, partial: message.partial,
365
365
  linesFailed: message.lfailed, target: message.target, targetDir: message.targetdir,
366
366
  owner: message.owner || "", orient: message.orient || 1, frames: message.frames || 0, photo: message.photo || null }
package/ui/Sidebar.qml CHANGED
@@ -713,15 +713,11 @@ Item {
713
713
  rest -= root.networkEntries.length
714
714
  return devRepeater.itemAt(rest)
715
715
  }
716
- // Above the Flickable and every row, because a PointHandler under their exclusive press grab is never given the press; a passive handler leaves the press to the row.
717
- Item {
718
- anchors.fill: parent
719
- z: 1
720
- PointHandler {
721
- acceptedButtons: Qt.AllButtons
722
- // After pressed(), which can hand an overlay rail the keyboard, so the press that claimed it still hides the frame.
723
- onActiveChanged: if (active) { root.pressed(); if (root.focused) root.pointerFocus = true }
724
- }
716
+ // Observe the whole sidebar, including its empty background. A sibling
717
+ // overlay does not receive DOM events dispatched to rows beneath it.
718
+ PointHandler {
719
+ acceptedButtons: Qt.AllButtons
720
+ onActiveChanged: if (active) { root.pressed(); if (root.focused) root.pointerFocus = true }
725
721
  }
726
722
  // The rail edge, one Divider shared with the column edges.
727
723
  Flea.Divider {