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
|
@@ -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});}
|
package/test/trashbrowse.test.js
CHANGED
|
@@ -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
|
|
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',()=>{
|
package/ui/ArchiveCards.qml
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
19
|
+
// One open at a time until xdg-open exits.
|
|
31
20
|
property bool pending: false
|
|
32
|
-
property
|
|
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.
|
|
45
|
-
child.command = [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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) {
|
|
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
|
-
|
|
41
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/ui/PreviewArchive.qml
CHANGED
|
@@ -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
|
|
package/ui/PreviewColumn.qml
CHANGED
package/ui/QuickLookArchive.qml
CHANGED
|
@@ -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
|
}
|
package/ui/SelectionPreview.qml
CHANGED
|
@@ -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
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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 {
|