From 269c305efabc825b8f2ccc592026f2544fbdd66e Mon Sep 17 00:00:00 2001 From: avillagran Date: Fri, 4 Sep 2026 04:36:04 -0400 Subject: [PATCH 1/3] fix: aarch64 support for statfs and renameat2 FFI calls --- src/backend/fsinfo.rs | 2 +- src/backend/ops.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/fsinfo.rs b/src/backend/fsinfo.rs index 03b9da27..b6ee84a5 100644 --- a/src/backend/fsinfo.rs +++ b/src/backend/fsinfo.rs @@ -65,7 +65,7 @@ pub fn read(path: &Path) -> Option { let c = CString::new(path.as_os_str().as_encoded_bytes()).ok()?; // Every field is written by the call, so the zeroed value is never read as a result. let mut buf: StatFs = unsafe { std::mem::zeroed() }; - if unsafe { statfs(c.as_ptr(), &mut buf) } != 0 { + if unsafe { statfs(c.as_ptr() as *const i8, &mut buf) } != 0 { return None; } Some(Info { name: name_for(buf.f_type), free: buf.f_bavail.saturating_mul(buf.f_bsize.max(0) as u64) }) diff --git a/src/backend/ops.rs b/src/backend/ops.rs index de43d0f0..6901e558 100644 --- a/src/backend/ops.rs +++ b/src/backend/ops.rs @@ -32,7 +32,7 @@ pub fn rename_noreplace(from: &Path, to: &Path) -> Result<(), FleaError> { // corner: a path with an interior NUL cannot reach a syscall, and no listing can produce one. _ => return Err(named("rename", to, "path contains an interior NUL")), }; - let rc = unsafe { renameat2(AT_FDCWD, c_from.as_ptr(), AT_FDCWD, c_to.as_ptr(), RENAME_NOREPLACE) }; + let rc = unsafe { renameat2(AT_FDCWD, c_from.as_ptr() as *const i8, AT_FDCWD, c_to.as_ptr() as *const i8, RENAME_NOREPLACE) }; if rc == 0 { return Ok(()); } From 023d064aa27299b767c90df4159758ec2a09028a Mon Sep 17 00:00:00 2001 From: avillagran Date: Mon, 7 Sep 2026 03:03:20 -0300 Subject: [PATCH 2/3] feat(ui): refresh listings from filesystem changes --- keys.toml | 9 +++++++ tests/js/keymap.js | 5 ++-- tests/js/menu.js | 9 ++++++- ui/ContextMenu.qml | 5 +++- ui/Pane.qml | 58 ++++++++++++++++++++++++++++++++++++++++++++++ ui/js/Focus.js | 4 ++++ ui/js/Keymap.js | 2 ++ ui/js/Menu.js | 4 ++++ ui/js/Nav.js | 4 ++++ 9 files changed, 96 insertions(+), 4 deletions(-) diff --git a/keys.toml b/keys.toml index 7103c07f..cfd89353 100644 --- a/keys.toml +++ b/keys.toml @@ -5,6 +5,10 @@ key = "D" action = "pageDown" +[[ctrl]] +key = "R" +action = "refresh" + [[ctrl]] key = "U" action = "pageUp" @@ -403,6 +407,11 @@ keys = "r" action = "rename" label = "rename" +[[sheet]] +keys = "^r" +action = "refresh" +label = "refresh" + [[sheet]] keys = "dd" action = "trashArm" diff --git a/tests/js/keymap.js b/tests/js/keymap.js index 5840ea07..89bea945 100644 --- a/tests/js/keymap.js +++ b/tests/js/keymap.js @@ -58,6 +58,7 @@ function run(check) { check("ctrl delete trashes", Keymap.lookup(Qt.Key_Delete, "", ctrl), "trash") check("ctrl up goes to the parent", Keymap.lookup(Qt.Key_Up, "", ctrl), "parent") check("ctrl down opens", Keymap.lookup(Qt.Key_Down, "", ctrl), "open") + check("ctrl r refreshes listing", Keymap.lookup(Qt.Key_R, "\u0012", ctrl), "refresh") check("ctrl 1, 2 and 3 pick the list, columns and grid views", [Keymap.lookup(Qt.Key_1, "1", ctrl), Keymap.lookup(Qt.Key_2, "2", ctrl), Keymap.lookup(Qt.Key_3, "3", ctrl)].join("|"), @@ -104,14 +105,14 @@ function run(check) { Keymap.SHEET.map(sheetAction).join("|"), Keymap.SHEET.map(function (row) { return row.action }).join("|")) check("the sheet is not empty, so the check above has a denominator", - Keymap.SHEET.length, 24) + Keymap.SHEET.length, 25) // A chord shares the row of the key it doubles, so every caret token must resolve to that row's // own action, or the sheet advertises a chord bound to something else. check("every chord the sheet draws is bound to the action of its own row", Keymap.SHEET.map(chordActions).join("|"), Keymap.SHEET.map(function (row) { return chordTokens(row).map(function () { return row.action }).join("+") }).join("|")) check("and the sheet draws chords at all, so that check has a denominator", - Keymap.SHEET.filter(function (row) { return chordTokens(row).length > 0 }).length, 10) + Keymap.SHEET.filter(function (row) { return chordTokens(row).length > 0 }).length, 11) check("slash filters, and the sheet now draws the row for it", Keymap.SHEET.filter(function (r) { return r.keys === "/" }).length, 1) check("and the sheet draws m, so eject and unmount are not mouse-only affordances", diff --git a/tests/js/menu.js b/tests/js/menu.js index 20ad1d0f..8945aae1 100644 --- a/tests/js/menu.js +++ b/tests/js/menu.js @@ -85,8 +85,15 @@ function runMenu(check) { check("an empty listing still offers New folder and the hidden toggle", labels(Menu.listingEntries({ showHidden: false, hasRow: false, rowInDropbox: false, dropboxPath: "", taildropPeers: [], archiveFormats: [], - rowIsArchive: false, rowIsImage: false, canConvert: false })), + rowIsArchive: false, rowIsImage: false, canConvert: false, + canAutoRefresh: true })), "New folder|Show hidden files") + check("a listing with no auto-watch adds a manual Refrescar row", + labels(Menu.listingEntries({ showHidden: false, hasRow: false, rowInDropbox: false, + dropboxPath: "", taildropPeers: [], archiveFormats: [], + rowIsArchive: false, rowIsImage: false, canConvert: false, + canAutoRefresh: false })), + "Refrescar|New folder|Show hidden files") // ui/Header.qml's own rows, on a right click over the column titles. Four toggles, flipping // labels, each answering "col:"; Name is absent because it never hides. diff --git a/ui/ContextMenu.qml b/ui/ContextMenu.qml index fd0cf27d..7d1caa8d 100644 --- a/ui/ContextMenu.qml +++ b/ui/ContextMenu.qml @@ -31,6 +31,8 @@ Item { property bool rowInDropbox: false // False on a listing's empty space, where only the two rows that need no row make sense. property bool hasRow: true + // True when this pane can be watched by filesystem events and auto-refresh will keep it live. + property bool canAutoRefresh: true // The rail's own rows when ui/Sidebar.qml raised this menu, empty when the listing did. One // instance serves both: a second one in this tree takes the keyboard from the list, see AGENTS.md. @@ -96,7 +98,8 @@ Item { archiveFormats: root.archiveFormats, rowIsArchive: root.rowIsArchive, rowIsImage: root.rowIsImage, - canConvert: root.canConvert + canConvert: root.canConvert, + canAutoRefresh: root.canAutoRefresh }) } diff --git a/ui/Pane.qml b/ui/Pane.qml index 57448fd5..d041fb08 100644 --- a/ui/Pane.qml +++ b/ui/Pane.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell +import Quickshell.Io import "." as Flea import "js/DirSizes.js" as DirSizes import "js/Filter.js" as Filter @@ -132,9 +133,38 @@ FocusScope { readonly property bool canGoBack: root.history.length > 0 readonly property bool canGoUp: root.path.length > 1 + onPathChanged: { + // FileView reports loadFailed for directories because it reads file contents; that is + // expected here. The same object still watches the directory and emits fileChanged. + root.canAutoRefresh = root.path.length > 0 + root._autoRefreshQueued = false + } + + onListInFlightChanged: { + if (!root.listInFlight && root._autoRefreshQueued) { + root._autoRefreshQueued = false + root.requestAutoRefresh() + } + } + // The filesystem line the status bar draws, refreshed once per directory rather than per row. property string fsName: "" property real fsFree: 0 + // A live directory watch can refresh the listing when files appear, rename or remove. + // If the watch cannot attach, the menu offers "Refrescar" as a manual fallback. + property bool canAutoRefresh: false + property bool _autoRefreshQueued: false + + // A tiny settle avoids a storm from bursty inotify events, especially on big renames. + function requestAutoRefresh() { + if (!root.canAutoRefresh || root.path.length === 0) + return + if (root.listInFlight) { + root._autoRefreshQueued = true + return + } + autoRefreshTimer.restart() + } function goBack() { Nav.back(root) } @@ -348,6 +378,7 @@ FocusScope { canConvert: root.backend.canConvert rowIsArchive: root.cursorRow !== null && !root.cursorRow.d && Archive.isArchive(root.cursorRow.n) rowIsImage: root.cursorRow !== null && root.cursorRow.i === "image-x-generic" + canAutoRefresh: root.canAutoRefresh dropboxPath: sidebar.dropboxReady ? root.home + "/Dropbox" : "" // The separator is part of the test, or /home/gm/DropboxBackup would count as inside Dropbox. rowInDropbox: root.path === root.home + "/Dropbox" || root.path.indexOf(root.home + "/Dropbox/") === 0 @@ -360,6 +391,33 @@ FocusScope { } } + // Auto-refresh from filesystem watch events when the listing directory supports watching. + Timer { + id: autoRefreshTimer + interval: 250 + repeat: false + onTriggered: { + if (!root.canAutoRefresh || root.listInFlight || root.path.length === 0) + return + root.refresh("") + } + } + + FileView { + id: listingWatcher + path: root.path + watchChanges: true + printErrors: false + // Directory contents are not readable as FileView text, but fileChanged still watches + // the directory path; loadFailed is therefore expected and deliberately ignored. + onFileChanged: { + // A changed directory may fire before the first rows arrive; queue it so the visible listing + // lands after a settle instead of re-requesting while an older read is still in flight. + if (root.path.length > 0) + root.requestAutoRefresh() + } + } + // shell.qml's IPC reads this to assert menu contents without OCR, see docs "Testing". function menuEntries() { return menu.entries } function menuSubmenuGlyphs() { return menu.submenuGlyphs() } diff --git a/ui/js/Focus.js b/ui/js/Focus.js index fb050586..fe0860fa 100644 --- a/ui/js/Focus.js +++ b/ui/js/Focus.js @@ -239,6 +239,10 @@ function handleKey(event, root, sidebar) { root.keymapSheet.open(root) return true } + if (action === "refresh") { + root.refresh() + return true + } // Tabs are window-level, so t, w and the digits answer from the rail as well as the list. if (action.indexOf("tab") === 0) { root.act(action) diff --git a/ui/js/Keymap.js b/ui/js/Keymap.js index 9dcbecb7..0c80b49e 100644 --- a/ui/js/Keymap.js +++ b/ui/js/Keymap.js @@ -14,6 +14,7 @@ function lookup(key, text, modifiers) { if (key === Qt.Key_Period) return "toggleHidden" } if (key === Qt.Key_D) return "pageDown" + if (key === Qt.Key_R) return "refresh" if (key === Qt.Key_U) return "pageUp" if (key === Qt.Key_A) return "selectAll" if (key === Qt.Key_C) return "copy" @@ -105,6 +106,7 @@ var SHEET = [ { keys: "x ^x", action: "cut", label: "cut" }, { keys: "p ^v", action: "paste", label: "paste" }, { keys: "r", action: "rename", label: "rename" }, + { keys: "^r", action: "refresh", label: "refresh" }, { keys: "dd", action: "trashArm", label: "trash" }, { keys: "z ^z", action: "undo", label: "undo" }, { keys: "^N", action: "newFolder", label: "new folder" }, diff --git a/ui/js/Menu.js b/ui/js/Menu.js index 37e06d60..3c25e825 100644 --- a/ui/js/Menu.js +++ b/ui/js/Menu.js @@ -61,6 +61,10 @@ function listingEntries(p) { out.push({ label: "Move to Trash", action: "trash", glyph: "trash", danger: true }) out.push({ separator: true }) } + // If this listing cannot be watched automatically, offer a manual refresh row. + if (p.canAutoRefresh === false) { + out.push({ label: "Refrescar", action: "refresh" }) + } // The last group is the rows that need no row under the cursor, which is also the whole menu // on a listing's empty space. out.push({ label: "New folder", action: "newFolder", glyph: "folder-plus" }) diff --git a/ui/js/Nav.js b/ui/js/Nav.js index 5d96f5aa..5ed8b539 100644 --- a/ui/js/Nav.js +++ b/ui/js/Nav.js @@ -44,6 +44,10 @@ function openWithoutHistory(pane, newPath) { pane.dirSizeState = DirSizes.empty() pane.cursorIndex = 0 pane.trashArmedAt = 0 + // A reset for watch-driven refresh after navigation; it is re-enabled once the new path + // can be watched at all. + pane._autoRefreshQueued = false + pane.canAutoRefresh = false // The row the editor sat on belongs to the listing being replaced, so the rename goes with it: // leaving the index set opened an empty editor over whatever file arrived at that row instead. pane.renamingIndex = -1 From 3e67f308d440c814913de080f54f48f75c5b0c0a Mon Sep 17 00:00:00 2001 From: avillagran Date: Mon, 7 Sep 2026 18:00:47 -0300 Subject: [PATCH 3/3] feat(ui): detect changes in current folder --- PKGBUILD | 2 +- ui/Pane.qml | 44 +++++++++++++++++++++++++++----------------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/PKGBUILD b/PKGBUILD index 7da17616..5e8a5b76 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -8,7 +8,7 @@ arch=('x86_64') license=('MIT') # omarchy owns /usr/share/omarchy/shell, which ui/Commons and ui/Ui link into; quickshell owns qs. # util-linux ships prlimit, which the thumbnail and archive sandboxes require alongside bubblewrap. -depends=('bubblewrap' 'glib2' 'omarchy' 'quickshell' 'shared-mime-info' 'util-linux' 'xdg-utils') +depends=('bubblewrap' 'glib2' 'inotify-tools' 'omarchy' 'quickshell' 'shared-mime-info' 'util-linux' 'xdg-utils') makedepends=('cargo') optdepends=('libarchive: archive listing and extraction' '7zip: 7z archive support' diff --git a/ui/Pane.qml b/ui/Pane.qml index d041fb08..a3b7a270 100644 --- a/ui/Pane.qml +++ b/ui/Pane.qml @@ -134,10 +134,14 @@ FocusScope { readonly property bool canGoUp: root.path.length > 1 onPathChanged: { - // FileView reports loadFailed for directories because it reads file contents; that is - // expected here. The same object still watches the directory and emits fileChanged. - root.canAutoRefresh = root.path.length > 0 + // Reconfigure the persistent directory watcher before the next path can produce events. + root.canAutoRefresh = false root._autoRefreshQueued = false + directoryWatcher.running = false + Qt.callLater(function () { + if (root.path.length > 0) + directoryWatcher.running = true + }) } onListInFlightChanged: { @@ -157,7 +161,7 @@ FocusScope { // A tiny settle avoids a storm from bursty inotify events, especially on big renames. function requestAutoRefresh() { - if (!root.canAutoRefresh || root.path.length === 0) + if (root.path.length === 0) return if (root.listInFlight) { root._autoRefreshQueued = true @@ -391,7 +395,8 @@ FocusScope { } } - // Auto-refresh from filesystem watch events when the listing directory supports watching. + // Auto-refresh from persistent directory events. FileView watches the path as a file, so it + // cannot observe a new child in a directory; inotifywait owns the directory watch instead. Timer { id: autoRefreshTimer interval: 250 @@ -403,18 +408,23 @@ FocusScope { } } - FileView { - id: listingWatcher - path: root.path - watchChanges: true - printErrors: false - // Directory contents are not readable as FileView text, but fileChanged still watches - // the directory path; loadFailed is therefore expected and deliberately ignored. - onFileChanged: { - // A changed directory may fire before the first rows arrive; queue it so the visible listing - // lands after a settle instead of re-requesting while an older read is still in flight. - if (root.path.length > 0) - root.requestAutoRefresh() + Process { + id: directoryWatcher + command: ["stdbuf", "-o0", "inotifywait", "--monitor", "--quiet", "--format", "%e", + "--event", "create,delete,moved_to,moved_from,close_write,attrib", root.path] + stdout: SplitParser { + splitMarker: "\n" + onRead: function (data) { + if (root.path.length > 0) + root.refresh("") + } + } + onStarted: root.canAutoRefresh = true + onExited: { + // A stop during path reconfiguration can report after the replacement watcher started; + // only a still-stopped process means automatic refresh is actually unavailable. + if (!directoryWatcher.running) + root.canAutoRefresh = false } }