flea-qml 0.0.0-stage → 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 (637) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +511 -2
  3. package/THIRD-PARTY-NOTICES.md +29 -0
  4. package/demos/App.java +5 -0
  5. package/demos/Dockerfile +4 -0
  6. package/demos/Main.kt +3 -0
  7. package/demos/Program.cs +1 -0
  8. package/demos/README.md +12 -0
  9. package/demos/app.dart +3 -0
  10. package/demos/app.deb +0 -0
  11. package/demos/app.js +1 -0
  12. package/demos/backup.tar +0 -0
  13. package/demos/budget.xlsx +0 -0
  14. package/demos/build.sh +2 -0
  15. package/demos/bundle.zip +0 -0
  16. package/demos/clip.mp4 +0 -0
  17. package/demos/data.sqlite +0 -0
  18. package/demos/disk.iso +0 -0
  19. package/demos/engine.cpp +5 -0
  20. package/demos/firmware.bin +0 -0
  21. package/demos/flake.nix +4 -0
  22. package/demos/folder/inside.txt +1 -0
  23. package/demos/font.ttf +0 -0
  24. package/demos/index.html +5 -0
  25. package/demos/index.php +2 -0
  26. package/demos/init.lua +1 -0
  27. package/demos/letter.docx +0 -0
  28. package/demos/lib.rs +3 -0
  29. package/demos/main.c +6 -0
  30. package/demos/main.h +3 -0
  31. package/demos/math-and-diagrams.md +200 -0
  32. package/demos/notes.tar.gz +0 -0
  33. package/demos/notes.txt +3 -0
  34. package/demos/photo.png +0 -0
  35. package/demos/program +2 -0
  36. package/demos/report.pdf +32 -0
  37. package/demos/script.py +1 -0
  38. package/demos/server.go +7 -0
  39. package/demos/settings.json +4 -0
  40. package/demos/slides.pptx +0 -0
  41. package/demos/song.mp3 +0 -0
  42. package/demos/style.css +4 -0
  43. package/demos/tool.rb +1 -0
  44. package/demos/types.ts +2 -0
  45. package/docs/pele.md +23 -0
  46. package/docs/protocol.md +3420 -0
  47. package/docs/temml.md +63 -0
  48. package/index.js +107 -0
  49. package/package.json +34 -4
  50. package/src/archive.js +407 -0
  51. package/src/archiveops.js +728 -0
  52. package/src/backend.js +369 -0
  53. package/src/bulkrename.js +95 -0
  54. package/src/clip-host.js +76 -0
  55. package/src/clip.js +462 -0
  56. package/src/code.js +168 -0
  57. package/src/compare.js +74 -0
  58. package/src/create.js +128 -0
  59. package/src/dirsize.js +147 -0
  60. package/src/figure-svg.js +530 -0
  61. package/src/figure-thread.js +23 -0
  62. package/src/figure.js +132 -0
  63. package/src/find.js +55 -0
  64. package/src/flea.js +39 -0
  65. package/src/fsinfo.js +39 -0
  66. package/src/highlight.js +141 -0
  67. package/src/html.js +52 -0
  68. package/src/kinds.js +89 -0
  69. package/src/listing.js +130 -0
  70. package/src/markdown-html.js +317 -0
  71. package/src/mathml.js +186 -0
  72. package/src/menu.js +180 -0
  73. package/src/meta.js +135 -0
  74. package/src/office.js +810 -0
  75. package/src/order.js +100 -0
  76. package/src/peek.js +49 -0
  77. package/src/pele-wrapper.js +695 -0
  78. package/src/permissions.js +180 -0
  79. package/src/places.js +313 -0
  80. package/src/preview-language.js +10 -0
  81. package/src/terminal-server.js +95 -0
  82. package/src/terminal-shell.js +26 -0
  83. package/src/transfer.js +669 -0
  84. package/src/trash-record.js +41 -0
  85. package/src/trash.js +210 -0
  86. package/src/trashbrowse.js +327 -0
  87. package/src/uistate.js +393 -0
  88. package/src/uistore.js +168 -0
  89. package/src/vendor/LICENSES/Apache-2.0.txt +202 -0
  90. package/src/vendor/LICENSES/MIT-katex-2015.txt +27 -0
  91. package/src/vendor/LICENSES/MIT-katex.txt +21 -0
  92. package/src/vendor/LICENSES/MIT-mermaid.txt +21 -0
  93. package/src/vendor/LICENSES/MIT-pele.txt +40 -0
  94. package/src/vendor/LICENSES/MIT-perseus.txt +18 -0
  95. package/src/vendor/LICENSES/MIT-temml.txt +21 -0
  96. package/src/vendor/pele.js +145 -0
  97. package/src/vendor/temml.mjs +14708 -0
  98. package/src/watch.js +98 -0
  99. package/src/wayland/client.js +109 -0
  100. package/src/wayland/protocol.js +55 -0
  101. package/src/wayland/protocols/ext-data-control-v1.xml +276 -0
  102. package/src/wayland/protocols/wayland.xml +3248 -0
  103. package/src/wayland/protocols/wlr-data-control-unstable-v1.xml +278 -0
  104. package/src/wayland/resource-io.js +6 -0
  105. package/src/wayland/socket-discovery.js +26 -0
  106. package/src/wayland/transport-posix.js +120 -0
  107. package/src/wayland/transport-win32.js +11 -0
  108. package/src/wayland/transport.js +3 -0
  109. package/src/wayland/win32-resources.js +80 -0
  110. package/src/wayland/win32-wire.js +62 -0
  111. package/test/archive.test.js +80 -0
  112. package/test/bench-markdown-scroll.mjs +190 -0
  113. package/test/clip-host.test.js +113 -0
  114. package/test/code-preview.test.js +35 -0
  115. package/test/compare-ui-paths.test.js +24 -0
  116. package/test/compare.test.js +54 -0
  117. package/test/delete-trash.test.js +67 -0
  118. package/test/dirsize.test.js +138 -0
  119. package/test/file-ops.test.js +307 -0
  120. package/test/file-url.test.js +24 -0
  121. package/test/find-contents.test.js +161 -0
  122. package/test/find-input.test.js +66 -0
  123. package/test/fixtures/pele-parity.json +1006 -0
  124. package/test/highlight-launcher.test.js +35 -0
  125. package/test/html-preview.test.js +22 -0
  126. package/test/input.test.js +15 -0
  127. package/test/keymap.test.js +26 -0
  128. package/test/language-icons.test.js +36 -0
  129. package/test/launcher.test.js +51 -0
  130. package/test/manual-archive-here-ui.mjs +47 -0
  131. package/test/manual-code-scroll.mjs +28 -0
  132. package/test/manual-column-scroll.mjs +24 -0
  133. package/test/manual-compare-ui.mjs +20 -0
  134. package/test/manual-find-status.mjs +37 -0
  135. package/test/manual-folder-focus.mjs +63 -0
  136. package/test/manual-html-security.mjs +23 -0
  137. package/test/manual-html-ui.mjs +30 -0
  138. package/test/manual-syntax-preview.mjs +26 -0
  139. package/test/manual-terminal-lifecycle.mjs +42 -0
  140. package/test/manual-terminal-navigation.mjs +48 -0
  141. package/test/manual-terminal-ui.mjs +47 -0
  142. package/test/manual-trash-ui.mjs +86 -0
  143. package/test/markdown-diagrams.test.js +93 -0
  144. package/test/markdown-html.test.js +105 -0
  145. package/test/mathml.test.js +92 -0
  146. package/test/menu-transfer.test.js +57 -0
  147. package/test/office-preview.test.js +154 -0
  148. package/test/pele-parity.test.js +28 -0
  149. package/test/places.test.js +134 -0
  150. package/test/preview-keys.test.js +60 -0
  151. package/test/preview-language.test.js +22 -0
  152. package/test/status-filename.test.js +20 -0
  153. package/test/transfer-replace-merge.test.js +187 -0
  154. package/test/trash-address.test.js +23 -0
  155. package/test/trash-view.test.js +37 -0
  156. package/test/trashbrowse.test.js +135 -0
  157. package/ui/AboutFacts.qml +78 -0
  158. package/ui/ArchiveCards.qml +95 -0
  159. package/ui/ArchiveCompressDialog.qml +378 -0
  160. package/ui/ArchiveHereConfirm.qml +127 -0
  161. package/ui/ArchivePasswordDialog.qml +257 -0
  162. package/ui/Backend.qml +775 -0
  163. package/ui/BulkOldNames.qml +47 -0
  164. package/ui/BulkRenameDialog.qml +380 -0
  165. package/ui/CardFrame.qml +67 -0
  166. package/ui/CardScroll.qml +79 -0
  167. package/ui/CheckBox.qml +73 -0
  168. package/ui/ChromeBar.qml +360 -0
  169. package/ui/ChromeButton.qml +131 -0
  170. package/ui/ChromeModes.qml +43 -0
  171. package/ui/CodeLines.qml +256 -0
  172. package/ui/CodeRefusal.qml +52 -0
  173. package/ui/CollideConfirm.qml +147 -0
  174. package/ui/CollideHost.qml +167 -0
  175. package/ui/ColumnPane.qml +525 -0
  176. package/ui/ColumnRow.qml +380 -0
  177. package/ui/ColumnsArea.qml +594 -0
  178. package/ui/ColumnsLanding.qml +147 -0
  179. package/ui/CompareView.qml +321 -0
  180. package/ui/ContextMenu.qml +743 -0
  181. package/ui/ConvertDialog.qml +456 -0
  182. package/ui/CountedWorker.qml +7 -0
  183. package/ui/Crumb.qml +64 -0
  184. package/ui/CrumbDrop.qml +25 -0
  185. package/ui/CsvTable.qml +394 -0
  186. package/ui/CursorMark.qml +41 -0
  187. package/ui/DefaultClaim.qml +130 -0
  188. package/ui/DeviceLock.qml +47 -0
  189. package/ui/DeviceMounts.qml +540 -0
  190. package/ui/DialogButton.qml +150 -0
  191. package/ui/DialogField.qml +147 -0
  192. package/ui/DialogHeader.qml +31 -0
  193. package/ui/DialogScrim.qml +20 -0
  194. package/ui/DialogTitle.qml +37 -0
  195. package/ui/Divider.qml +9 -0
  196. package/ui/DropInto.qml +126 -0
  197. package/ui/DropTarget.qml +35 -0
  198. package/ui/DropboxMark.qml +60 -0
  199. package/ui/EmptyState.qml +150 -0
  200. package/ui/EscapeHint.qml +11 -0
  201. package/ui/FactsTable.qml +54 -0
  202. package/ui/FastScrollHandler.qml +476 -0
  203. package/ui/FatNamesConfirm.qml +157 -0
  204. package/ui/Favourites.qml +143 -0
  205. package/ui/FigureService.qml +120 -0
  206. package/ui/FileDrag.qml +267 -0
  207. package/ui/FilterStrip.qml +133 -0
  208. package/ui/FitMetrics.qml +22 -0
  209. package/ui/FlashDriveRows.qml +137 -0
  210. package/ui/FlashHost.qml +72 -0
  211. package/ui/FlashImageRow.qml +104 -0
  212. package/ui/FlashRunner.qml +119 -0
  213. package/ui/FlashStepRow.qml +172 -0
  214. package/ui/FlashStrip.qml +38 -0
  215. package/ui/FlashView.qml +397 -0
  216. package/ui/FleaMark.qml +88 -0
  217. package/ui/FlyoutFade.qml +23 -0
  218. package/ui/FontPane.qml +170 -0
  219. package/ui/Glass.qml +114 -0
  220. package/ui/GlassTagger.qml +59 -0
  221. package/ui/Glyph.qml +64 -0
  222. package/ui/GlyphSolid.qml +22 -0
  223. package/ui/GridArea.qml +664 -0
  224. package/ui/GridTile.qml +325 -0
  225. package/ui/GvfsBridge.qml +164 -0
  226. package/ui/Header.qml +433 -0
  227. package/ui/HoverTimers.qml +39 -0
  228. package/ui/HtmlClip.qml +30 -0
  229. package/ui/HtmlPane.qml +63 -0
  230. package/ui/HyprlandMark.qml +45 -0
  231. package/ui/Ipc.qml +1005 -0
  232. package/ui/IpcControls.qml +7 -0
  233. package/ui/IpcMenuState.qml +48 -0
  234. package/ui/IpcPreviewState.qml +185 -0
  235. package/ui/JumpPath.qml +51 -0
  236. package/ui/KeymapSheet.qml +561 -0
  237. package/ui/KeymapSheetResult.qml +89 -0
  238. package/ui/KindStack.qml +40 -0
  239. package/ui/LangMark.qml +51 -0
  240. package/ui/LangMarkLoader.qml +26 -0
  241. package/ui/List.qml +480 -0
  242. package/ui/LoadingState.qml +54 -0
  243. package/ui/LocalSend.qml +56 -0
  244. package/ui/LocalSendMark.qml +73 -0
  245. package/ui/MarkdownBlockView.qml +311 -0
  246. package/ui/MarkdownBlocks.qml +35 -0
  247. package/ui/MarkdownFigure.qml +254 -0
  248. package/ui/MarkdownImages.qml +72 -0
  249. package/ui/MarkdownList.qml +81 -0
  250. package/ui/MarkdownMathsText.qml +97 -0
  251. package/ui/MarkdownPane.qml +83 -0
  252. package/ui/MarkdownPictures.qml +41 -0
  253. package/ui/MarkdownQuote.qml +52 -0
  254. package/ui/MarkdownRemote.qml +82 -0
  255. package/ui/MarkdownSource.qml +155 -0
  256. package/ui/MarkdownTable.qml +222 -0
  257. package/ui/MarkdownTableScroll.qml +64 -0
  258. package/ui/MarkdownTableWheel.qml +126 -0
  259. package/ui/MarkdownText.qml +87 -0
  260. package/ui/MarkdownWorker.js +132 -0
  261. package/ui/MatchText.qml +99 -0
  262. package/ui/MediaSession.qml +38 -0
  263. package/ui/MediaSound.qml +26 -0
  264. package/ui/MediaStrip.qml +191 -0
  265. package/ui/MediaTrack.qml +36 -0
  266. package/ui/MediaVolume.qml +54 -0
  267. package/ui/MenuActionDialog.qml +396 -0
  268. package/ui/MenuEdgeFade.qml +24 -0
  269. package/ui/MenuHost.qml +227 -0
  270. package/ui/MenuRow.qml +351 -0
  271. package/ui/MountListing.qml +100 -0
  272. package/ui/NetworkDialog.qml +435 -0
  273. package/ui/NetworkForm.qml +426 -0
  274. package/ui/NetworkMounts.qml +913 -0
  275. package/ui/NetworkPlaces.qml +127 -0
  276. package/ui/OpenWithDialog.qml +613 -0
  277. package/ui/Opener.qml +95 -0
  278. package/ui/Pane.qml +1320 -0
  279. package/ui/PaneMenuActions.qml +527 -0
  280. package/ui/PanePath.qml +90 -0
  281. package/ui/PaneRail.qml +268 -0
  282. package/ui/PaneStates.qml +88 -0
  283. package/ui/PaneSwap.qml +339 -0
  284. package/ui/PaneWire.qml +601 -0
  285. package/ui/ParkedWait.qml +9 -0
  286. package/ui/PathEntry.qml +186 -0
  287. package/ui/PathJump.qml +400 -0
  288. package/ui/PdfViewer.qml +297 -0
  289. package/ui/PermissionsDialog.qml +658 -0
  290. package/ui/PermissionsHeadings.qml +31 -0
  291. package/ui/PhoneMounts.qml +67 -0
  292. package/ui/PickerAnswers.qml +75 -0
  293. package/ui/PickerBox.qml +26 -0
  294. package/ui/PickerChrome.qml +295 -0
  295. package/ui/PickerFilter.qml +97 -0
  296. package/ui/PickerGrid.qml +308 -0
  297. package/ui/PickerHeader.qml +28 -0
  298. package/ui/PickerLifecycle.qml +27 -0
  299. package/ui/PickerList.qml +218 -0
  300. package/ui/PickerListing.qml +149 -0
  301. package/ui/PickerPathBar.qml +127 -0
  302. package/ui/PickerPlaces.qml +189 -0
  303. package/ui/PickerRecent.qml +92 -0
  304. package/ui/PickerSave.qml +101 -0
  305. package/ui/PickerSelection.qml +155 -0
  306. package/ui/PickerWindow.qml +846 -0
  307. package/ui/PowerSectorsReader.qml +10 -0
  308. package/ui/PrebuildDriver.qml +146 -0
  309. package/ui/Preview.qml +1005 -0
  310. package/ui/PreviewAnimation.qml +24 -0
  311. package/ui/PreviewArchive.qml +91 -0
  312. package/ui/PreviewColumn.qml +553 -0
  313. package/ui/PreviewEdge.qml +63 -0
  314. package/ui/PreviewFacts.qml +30 -0
  315. package/ui/PreviewFactsLine.qml +34 -0
  316. package/ui/PreviewHtml.qml +233 -0
  317. package/ui/PreviewImage.qml +253 -0
  318. package/ui/PreviewImagePan.qml +13 -0
  319. package/ui/PreviewImageSharp.qml +32 -0
  320. package/ui/PreviewLines.qml +106 -0
  321. package/ui/PreviewMarkdown.qml +706 -0
  322. package/ui/PreviewMedia.qml +250 -0
  323. package/ui/PreviewPdf.qml +482 -0
  324. package/ui/PreviewScroll.qml +201 -0
  325. package/ui/PreviewSwap.qml +335 -0
  326. package/ui/PreviewText.qml +149 -0
  327. package/ui/ProtocolChip.qml +18 -0
  328. package/ui/QuickLookArchive.qml +41 -0
  329. package/ui/QuickLookBar.qml +103 -0
  330. package/ui/QuickLookPrepare.qml +355 -0
  331. package/ui/QuickLookSwap.qml +10 -0
  332. package/ui/QuickLookWarm.qml +65 -0
  333. package/ui/RailDrop.qml +155 -0
  334. package/ui/RailHeading.qml +19 -0
  335. package/ui/RailPlaces.qml +38 -0
  336. package/ui/RailTrashRow.qml +67 -0
  337. package/ui/RenameField.qml +353 -0
  338. package/ui/RendererRetry.qml +11 -0
  339. package/ui/ResizeHandle.qml +52 -0
  340. package/ui/Reveal.qml +54 -0
  341. package/ui/Row.qml +476 -0
  342. package/ui/RowDrag.qml +213 -0
  343. package/ui/RowDropTarget.qml +27 -0
  344. package/ui/RunCard.qml +286 -0
  345. package/ui/Scripts.qml +65 -0
  346. package/ui/SearchStrip.qml +232 -0
  347. package/ui/SelectionBand.qml +140 -0
  348. package/ui/SelectionPreview.qml +370 -0
  349. package/ui/SettingsFavourite.qml +122 -0
  350. package/ui/SettingsGroup.qml +96 -0
  351. package/ui/SettingsHint.qml +12 -0
  352. package/ui/SettingsMetrics.qml +29 -0
  353. package/ui/SettingsPane.qml +178 -0
  354. package/ui/SettingsPanel.qml +547 -0
  355. package/ui/SettingsRail.qml +128 -0
  356. package/ui/SettingsRow.qml +409 -0
  357. package/ui/SettingsRuler.qml +85 -0
  358. package/ui/SettingsSegment.qml +85 -0
  359. package/ui/ShareBrowser.qml +113 -0
  360. package/ui/ShareLink.qml +67 -0
  361. package/ui/ShelfPins.qml +101 -0
  362. package/ui/Sidebar.qml +728 -0
  363. package/ui/SidebarRow.qml +358 -0
  364. package/ui/Spinner.qml +60 -0
  365. package/ui/StableModel.qml +89 -0
  366. package/ui/StateMessage.qml +84 -0
  367. package/ui/StatusBar.qml +512 -0
  368. package/ui/StatusFileName.qml +27 -0
  369. package/ui/TabBar.qml +673 -0
  370. package/ui/TabDragGeometry.qml +60 -0
  371. package/ui/TagMenuMark.qml +52 -0
  372. package/ui/TagRailSection.qml +53 -0
  373. package/ui/TagSlot.qml +40 -0
  374. package/ui/TagState.qml +150 -0
  375. package/ui/TagSwatch.qml +28 -0
  376. package/ui/Taildrop.qml +86 -0
  377. package/ui/TailscaleMark.qml +50 -0
  378. package/ui/TerminalService.qml +69 -0
  379. package/ui/TerminalTab.qml +48 -0
  380. package/ui/TerminalTabs.qml +27 -0
  381. package/ui/Theme.qml +460 -0
  382. package/ui/ThemeColors.qml +43 -0
  383. package/ui/TransferCard.qml +239 -0
  384. package/ui/TrashConfirm.qml +129 -0
  385. package/ui/TrashDropHandler.qml +11 -0
  386. package/ui/TrashHost.qml +202 -0
  387. package/ui/TrashMonitor.qml +30 -0
  388. package/ui/TrashToolbar.qml +19 -0
  389. package/ui/TrashView.qml +492 -0
  390. package/ui/UnlockDialog.qml +450 -0
  391. package/ui/UnlockHost.qml +206 -0
  392. package/ui/UpdateCheck.qml +97 -0
  393. package/ui/UserDirs.qml +25 -0
  394. package/ui/ViewState.qml +565 -0
  395. package/ui/ViewportScrollBar.qml +195 -0
  396. package/ui/ViewportScrollBars.qml +25 -0
  397. package/ui/WebPreview.qml +33 -0
  398. package/ui/WebPreviewView.qml +26 -0
  399. package/ui/WindowBody.qml +829 -0
  400. package/ui/WindowDrag.qml +35 -0
  401. package/ui/boot/Commons/AudioNodes.qml +50 -0
  402. package/ui/boot/Commons/AudioNodesModel.js +96 -0
  403. package/ui/boot/Commons/Border.qml +243 -0
  404. package/ui/boot/Commons/BorderGeometry.js +373 -0
  405. package/ui/boot/Commons/Color.qml +258 -0
  406. package/ui/boot/Commons/I18n.qml +118 -0
  407. package/ui/boot/Commons/InputDevices.qml +112 -0
  408. package/ui/boot/Commons/InputMethodState.qml +38 -0
  409. package/ui/boot/Commons/IpcRegistry.qml +77 -0
  410. package/ui/boot/Commons/Numbers.js +19 -0
  411. package/ui/boot/Commons/Plural.js +91 -0
  412. package/ui/boot/Commons/ShellIpc.qml +12 -0
  413. package/ui/boot/Commons/Style.qml +561 -0
  414. package/ui/boot/Commons/Translations.qml +100 -0
  415. package/ui/boot/Commons/UntypedInput.qml +152 -0
  416. package/ui/boot/Commons/Util.qml +188 -0
  417. package/ui/boot/Commons/qmldir +13 -0
  418. package/ui/boot/LICENSE-omarchy +20 -0
  419. package/ui/boot/Ui/BackgroundMedia.qml +49 -0
  420. package/ui/boot/Ui/BarIconButton.qml +106 -0
  421. package/ui/boot/Ui/BarIconContent.qml +38 -0
  422. package/ui/boot/Ui/BarIndicator.qml +53 -0
  423. package/ui/boot/Ui/BarWidget.qml +50 -0
  424. package/ui/boot/Ui/BorderOverlay.qml +54 -0
  425. package/ui/boot/Ui/BorderSurface.qml +40 -0
  426. package/ui/boot/Ui/Button.qml +224 -0
  427. package/ui/boot/Ui/ButtonGroup.qml +131 -0
  428. package/ui/boot/Ui/ConfirmDialog.qml +134 -0
  429. package/ui/boot/Ui/CursorSurface.qml +42 -0
  430. package/ui/boot/Ui/Dropdown.qml +245 -0
  431. package/ui/boot/Ui/FilterInput.qml +26 -0
  432. package/ui/boot/Ui/IconContentBounds.qml +141 -0
  433. package/ui/boot/Ui/KeyboardPanel.qml +453 -0
  434. package/ui/boot/Ui/MultiSelect.qml +624 -0
  435. package/ui/boot/Ui/NumberField.qml +86 -0
  436. package/ui/boot/Ui/OpticalGlyph.qml +68 -0
  437. package/ui/boot/Ui/OverlayWindow.qml +102 -0
  438. package/ui/boot/Ui/Panel.qml +60 -0
  439. package/ui/boot/Ui/PanelActionButton.qml +105 -0
  440. package/ui/boot/Ui/PanelController.qml +16 -0
  441. package/ui/boot/Ui/PanelHero.qml +112 -0
  442. package/ui/boot/Ui/PanelKeyCatcher.qml +100 -0
  443. package/ui/boot/Ui/PanelSectionHeader.qml +31 -0
  444. package/ui/boot/Ui/PanelSeparator.qml +19 -0
  445. package/ui/boot/Ui/PanelSlider.qml +156 -0
  446. package/ui/boot/Ui/PanelToolTip.qml +50 -0
  447. package/ui/boot/Ui/PluginBarApi.qml +89 -0
  448. package/ui/boot/Ui/PointerMoveGate.qml +54 -0
  449. package/ui/boot/Ui/PopupCard.qml +181 -0
  450. package/ui/boot/Ui/ScreenMoveRemap.qml +43 -0
  451. package/ui/boot/Ui/SearchableDropdown.qml +354 -0
  452. package/ui/boot/Ui/SizedImage.qml +125 -0
  453. package/ui/boot/Ui/SpeedTestOverlay.qml +412 -0
  454. package/ui/boot/Ui/TextField.qml +58 -0
  455. package/ui/boot/Ui/Toggle.qml +118 -0
  456. package/ui/boot/Ui/ToggleSwitch.qml +115 -0
  457. package/ui/boot/Ui/WidgetButton.qml +172 -0
  458. package/ui/boot/Ui/qmldir +41 -0
  459. package/ui/boot/fleatab.qml +50 -0
  460. package/ui/boot/picker.qml +39 -0
  461. package/ui/boot/shell.qml +131 -0
  462. package/ui/boot/tabtearoff.qml +51 -0
  463. package/ui/js/Anchor.js +379 -0
  464. package/ui/js/AnchorHold.js +162 -0
  465. package/ui/js/Archive.js +157 -0
  466. package/ui/js/ArchiveCard.js +44 -0
  467. package/ui/js/ArchiveOps.js +44 -0
  468. package/ui/js/BackReturn.js +118 -0
  469. package/ui/js/BulkRename.js +100 -0
  470. package/ui/js/Buttons.js +54 -0
  471. package/ui/js/ChromeKeys.js +116 -0
  472. package/ui/js/ClipMarks.js +73 -0
  473. package/ui/js/ClipSelection.js +67 -0
  474. package/ui/js/Clipboard.js +179 -0
  475. package/ui/js/Cloud.js +97 -0
  476. package/ui/js/CodeRuns.js +72 -0
  477. package/ui/js/Collide.js +212 -0
  478. package/ui/js/ColumnFit.js +28 -0
  479. package/ui/js/ColumnMenu.js +111 -0
  480. package/ui/js/Columns.js +298 -0
  481. package/ui/js/ColumnsCarry.js +103 -0
  482. package/ui/js/Compare.js +74 -0
  483. package/ui/js/Contrast.js +157 -0
  484. package/ui/js/ControlReader.js +42 -0
  485. package/ui/js/Convert.js +52 -0
  486. package/ui/js/CopyAs.js +52 -0
  487. package/ui/js/Crumbs.js +178 -0
  488. package/ui/js/Csv.js +231 -0
  489. package/ui/js/Density.js +44 -0
  490. package/ui/js/Devices.js +440 -0
  491. package/ui/js/DirSizes.js +180 -0
  492. package/ui/js/Drag.js +360 -0
  493. package/ui/js/DragOut.js +166 -0
  494. package/ui/js/DropDev.js +52 -0
  495. package/ui/js/Dropbox.js +41 -0
  496. package/ui/js/Eject.js +217 -0
  497. package/ui/js/Errors.js +188 -0
  498. package/ui/js/ExtThumbs.js +115 -0
  499. package/ui/js/Facts.js +402 -0
  500. package/ui/js/FigureDate.js +14 -0
  501. package/ui/js/Filter.js +285 -0
  502. package/ui/js/Find.js +46 -0
  503. package/ui/js/Flash.js +184 -0
  504. package/ui/js/FlashCard.js +79 -0
  505. package/ui/js/FlashEndings.js +125 -0
  506. package/ui/js/FlashPane.js +116 -0
  507. package/ui/js/FleaBin.js +12 -0
  508. package/ui/js/Focus.js +504 -0
  509. package/ui/js/FolderSorts.js +108 -0
  510. package/ui/js/FolderViews.js +44 -0
  511. package/ui/js/Format.js +314 -0
  512. package/ui/js/Fuzzy.js +141 -0
  513. package/ui/js/GlassTag.js +72 -0
  514. package/ui/js/Grid.js +33 -0
  515. package/ui/js/GridGeometry.js +90 -0
  516. package/ui/js/GridNames.js +153 -0
  517. package/ui/js/GvfsBridge.js +200 -0
  518. package/ui/js/HostClipboard.js +29 -0
  519. package/ui/js/Icons.js +218 -0
  520. package/ui/js/Input.js +50 -0
  521. package/ui/js/Jump.js +200 -0
  522. package/ui/js/KeyName.js +64 -0
  523. package/ui/js/Keymap.js +401 -0
  524. package/ui/js/Kinds.js +164 -0
  525. package/ui/js/LICENSES/ISC-lucide.txt +43 -0
  526. package/ui/js/LangMarkPaths.js +47 -0
  527. package/ui/js/LangMarks.js +120 -0
  528. package/ui/js/Lists.js +11 -0
  529. package/ui/js/LocalSend.js +39 -0
  530. package/ui/js/LockedMenu.js +63 -0
  531. package/ui/js/MakeDefault.js +186 -0
  532. package/ui/js/MarkBox.js +7 -0
  533. package/ui/js/Markdown.js +119 -0
  534. package/ui/js/MarkdownLists.js +86 -0
  535. package/ui/js/MarkdownMaths.js +58 -0
  536. package/ui/js/MarkdownPictures.js +32 -0
  537. package/ui/js/MarkdownPrepared.js +139 -0
  538. package/ui/js/MarkdownTableFit.js +34 -0
  539. package/ui/js/Marks.js +148 -0
  540. package/ui/js/Marquee.js +96 -0
  541. package/ui/js/Match.js +93 -0
  542. package/ui/js/Material.js +109 -0
  543. package/ui/js/MdBlocks.js +437 -0
  544. package/ui/js/MdBreak.js +71 -0
  545. package/ui/js/MdChunks.js +70 -0
  546. package/ui/js/MdContainer.js +137 -0
  547. package/ui/js/MdDocument.js +337 -0
  548. package/ui/js/MdEmph.js +97 -0
  549. package/ui/js/MdEntity.js +108 -0
  550. package/ui/js/MdEntityTable.js +223 -0
  551. package/ui/js/MdEscape.js +59 -0
  552. package/ui/js/MdFront.js +37 -0
  553. package/ui/js/MdHold.js +32 -0
  554. package/ui/js/MdHtml.js +307 -0
  555. package/ui/js/MdHtmlBlock.js +290 -0
  556. package/ui/js/MdHtmlHeading.js +53 -0
  557. package/ui/js/MdHtmlImage.js +179 -0
  558. package/ui/js/MdInline.js +154 -0
  559. package/ui/js/MdItems.js +212 -0
  560. package/ui/js/MdLeaf.js +309 -0
  561. package/ui/js/MdLink.js +196 -0
  562. package/ui/js/MdMath.js +67 -0
  563. package/ui/js/MdParagraphs.js +138 -0
  564. package/ui/js/MdRefs.js +232 -0
  565. package/ui/js/MdResolve.js +226 -0
  566. package/ui/js/MdRun.js +300 -0
  567. package/ui/js/MdUrl.js +226 -0
  568. package/ui/js/Menu.js +514 -0
  569. package/ui/js/MenuContext.js +85 -0
  570. package/ui/js/MenuFit.js +13 -0
  571. package/ui/js/MenuRefresh.js +74 -0
  572. package/ui/js/MenuWheel.js +35 -0
  573. package/ui/js/Messages.js +186 -0
  574. package/ui/js/Motion.js +16 -0
  575. package/ui/js/Mounts.js +320 -0
  576. package/ui/js/MouseNav.js +13 -0
  577. package/ui/js/Nav.js +329 -0
  578. package/ui/js/NetFs.js +26 -0
  579. package/ui/js/OpenWith.js +83 -0
  580. package/ui/js/Ops.js +585 -0
  581. package/ui/js/Palette.js +33 -0
  582. package/ui/js/PathBar.js +260 -0
  583. package/ui/js/Permissions.js +179 -0
  584. package/ui/js/Phones.js +112 -0
  585. package/ui/js/Picker.js +353 -0
  586. package/ui/js/PlaceMenu.js +96 -0
  587. package/ui/js/Places.js +265 -0
  588. package/ui/js/PreviewAsk.js +21 -0
  589. package/ui/js/PreviewKeys.js +155 -0
  590. package/ui/js/PreviewSettle.js +29 -0
  591. package/ui/js/PreviewSwap.js +101 -0
  592. package/ui/js/PropertyFacts.js +144 -0
  593. package/ui/js/Protocols.js +296 -0
  594. package/ui/js/QuickLookKinds.js +27 -0
  595. package/ui/js/QuickLookRect.js +24 -0
  596. package/ui/js/RailDrop.js +51 -0
  597. package/ui/js/RailKeys.js +87 -0
  598. package/ui/js/RailMenu.js +133 -0
  599. package/ui/js/Recent.js +135 -0
  600. package/ui/js/RecentMode.js +156 -0
  601. package/ui/js/Reload.js +60 -0
  602. package/ui/js/Renderer.js +9 -0
  603. package/ui/js/RunFile.js +143 -0
  604. package/ui/js/Scripts.js +28 -0
  605. package/ui/js/Scroll.js +304 -0
  606. package/ui/js/ScrollOff.js +69 -0
  607. package/ui/js/Search.js +478 -0
  608. package/ui/js/Selection.js +97 -0
  609. package/ui/js/Settings.js +476 -0
  610. package/ui/js/SettingsAbout.js +97 -0
  611. package/ui/js/SettingsShelf.js +52 -0
  612. package/ui/js/SheetKeys.js +42 -0
  613. package/ui/js/SheetQuery.js +281 -0
  614. package/ui/js/ShelfPile.js +27 -0
  615. package/ui/js/SlowClick.js +53 -0
  616. package/ui/js/SlowOp.js +6 -0
  617. package/ui/js/Sort.js +134 -0
  618. package/ui/js/Startup.js +63 -0
  619. package/ui/js/StateWords.js +17 -0
  620. package/ui/js/Status.js +238 -0
  621. package/ui/js/Swap.js +166 -0
  622. package/ui/js/TabMove.js +39 -0
  623. package/ui/js/Tabs.js +751 -0
  624. package/ui/js/Tags.js +134 -0
  625. package/ui/js/Taildrop.js +128 -0
  626. package/ui/js/Tap.js +180 -0
  627. package/ui/js/TextSize.js +107 -0
  628. package/ui/js/Thumbs.js +158 -0
  629. package/ui/js/Transfer.js +172 -0
  630. package/ui/js/Trash.js +84 -0
  631. package/ui/js/TrashDates.js +47 -0
  632. package/ui/js/TrashDrop.js +64 -0
  633. package/ui/js/UiState.js +438 -0
  634. package/ui/js/Unlock.js +152 -0
  635. package/ui/js/Update.js +130 -0
  636. package/ui/js/UserDirs.js +56 -0
  637. package/ui/qmldir +230 -0
@@ -0,0 +1,3420 @@
1
+ # Flea backend protocol
2
+
3
+ One JSON object per line, newline-delimited, over the `flea --backend` child's stdin
4
+ and stdout. No batching, no length prefix: `Quickshell.Io.Process` reads with a
5
+ `SplitParser` on `\n`. Implemented in `src/backend/proto.rs` over the field scanner and
6
+ escaper in `src/json.rs`, dispatched by `src/backend/run.rs`.
7
+
8
+ ## Invariants
9
+
10
+ 1. A successful `list` is always followed by a `rows` line, even when `first` is
11
+ 0, in which case the array is empty. A `list` whose path cannot be read
12
+ answers a single `error` line instead, and no `rows` line follows.
13
+ 2. Errors are messages, never exit statuses. `--backend` runs until it reads `quit`
14
+ or stdin closes, and returns exit code 0 either way; every failure is an `error`
15
+ line the client reads off stdout, not a process exit code.
16
+ 3. Exactly one line arrives unasked: `changed`, which says the listed directory was
17
+ altered by another program. It can land between any request and its reply, so a
18
+ client that counts lines rather than reading their `t` field will misread the wire.
19
+ 4. A row index means a file only in the numbering it was read from. Every `rows` line
20
+ names that numbering as `listing`, and a `trash`, `transfer`, `link`, `paths`, `collisions`, `settags`
21
+ or `menuaction` that names an older one is refused before anything resolves; see "listing" below.
22
+
23
+ ## Requests
24
+
25
+ ### listing
26
+
27
+ `"listing":<uint>` on a request that carries `rows`
28
+
29
+ Example: `{"c":"trash","rows":[0],"menuId":0,"listing":7}`
30
+
31
+ The backend numbers its rows, and the number moves whenever what an index names changes: a
32
+ successful `list` or `listpaths`, a `search`, an accepted `sort`, and a walk's ranking. Every `rows`
33
+ line carries the numbering it was written in as its last field, `"listing":<uint>`, and the first
34
+ listing a backend makes is 1. A client names the numbering of the rows it read an index from on any
35
+ request that carries `rows`. A `trash`, `transfer`, `link`, `paths`, `collisions`, `settags` or `menuaction` whose `listing` is not
36
+ the numbering in force is refused before a single index is resolved: `trash`, `transfer`, `link`, `paths`,
37
+ `collisions` and `settags` answer `{"t":"error","where":"stale","path":"<the command>","msg":"..."}` and nothing else, and a
38
+ `menuaction` answers its own reply with `"ok":false` and an `error` sentence. Nothing was done in
39
+ either case, so a client that wants the action reads the new rows and asks again.
40
+
41
+ A request that names no `listing` is resolved against the listing in force, exactly as before the
42
+ field existed, so an older client and a hand-written line keep working. `thumb`, `thumbcancel`,
43
+ `dirsize`, `meta`, `tagstates` and `window` read and never act on a file, so they are not refused; a client drops
44
+ their answers for a listing it has left. The race this closes is a request written after a `list`
45
+ and before its `rows` reached the client: the backend has the new listing by then, and without the
46
+ field it resolved the old index against it, so `list A`, `list B`, `paths [0]` answered B's first file.
47
+
48
+ ### list
49
+
50
+ `{"c":"list","path":"<string>","first":<uint>,"hidden":<bool>}`
51
+
52
+ Example: `{"c":"list","path":"/home/gm","first":350,"hidden":false}`
53
+
54
+ Scans `path` (phase 1: names and the directory bit only), applies ordering, and
55
+ answers a `listed` line followed immediately by a `rows` line
56
+ covering rows `0..first`, or the window `select` below moves onto its row. A `path` that fails to scan answers a single `error` line
57
+ instead and leaves the previously listed directory in place: a failed `list` cannot
58
+ mix a new `path` with the old listing. A missing `path` defaults to the empty string,
59
+ a missing `first` defaults to `0`.
60
+
61
+ Optional `by`, `desc`, `foldersFirst`, and `groupByKind` fields use the ordering
62
+ rules described under `sort`. A list with no ordering fields uses name ascending,
63
+ directories first, without kind groups. Unlike an explicit `sort`, a `list` may
64
+ omit `by`. An invalid ordering key refuses the new listing and keeps the previous
65
+ directory and rows.
66
+
67
+ Optional `hiddenLast`, `false` unless `true`: with it on, dotfiles form an outer
68
+ partition, a visible block then a hidden block, for each of the four keys in both
69
+ directions, and `foldersFirst` or `groupByKind` orders inside each block, so visible
70
+ folders come first, then visible files, then hidden folders, then hidden files. Off
71
+ leaves dotfiles to follow the sort key like any other name. Absent is off, so an
72
+ older client keeps it.
73
+
74
+ `hidden` of `false`, or a missing `hidden`, drops every name starting with `.` before
75
+ it ever reaches the listing: the filter runs inside the scan itself, not as a later
76
+ pass over it, so a hidden directory costs nothing beyond the `readdir` entry it was
77
+ always going to read. `hidden` of `true` scans dotfiles in too, ordered the same as
78
+ everything else. There is no separate flag to ask for dotfiles without also
79
+ re-scanning: `sort` reorders whichever listing `list` last produced and cannot add or
80
+ remove rows, so changing `hidden` always means a fresh `list`, which is also what
81
+ clears the cursor and selection back to row 0.
82
+
83
+ Optional `wantChanged`, `false` unless `true`: with it on, a `list` that re-reads the
84
+ directory already listed names added plus removed rows in `changed`. Absent is off, so
85
+ every navigation and watched re-read stays silent and pays no count.
86
+
87
+ Optional `select`, a full path such as `"/home/gm/notes.txt"`: when it names a row of the new
88
+ listing at index `first` or past it, the `rows` line covers the `first` rows that hold that row
89
+ near their middle, starting at `index - first / 2` and clamped so the window ends at the last
90
+ row, instead of `0..first`; its `start` says where. A row inside `0..first`, a name the listing
91
+ does not hold, a path in another directory or an absent `select` answers `0..first` as before.
92
+ The lookup reads only the listing's name arena, matching names exactly the way `locate` does,
93
+ and stats nothing beyond the window it answers. The client asks for the directory with the row
94
+ it will put the cursor on, so a row sorted far down lands in the listing's first rows reply.
95
+
96
+ ### locate
97
+
98
+ `{"c":"locate","path":"/directory/selected.txt"}`
99
+
100
+ Returns `{"t":"located","directory":"/directory","path":"/directory/selected.txt","index":42}`
101
+ for a name already in the current listing, or `index:-1` when absent or invalid. This reads only
102
+ the listing's name arena; it does not stat files, resolve symlinks or scan another directory.
103
+ The client must match both `directory` and the requested `path` before using the index, then
104
+ fetch only the viewport containing that row. This is location in the current listing, not proof
105
+ that the filesystem object still exists or has the same identity for a later operation.
106
+
107
+ `{"c":"locate","paths":["/directory/selected.txt"],"id":7,"menuId":4}` resolves a batch
108
+ in one scan of the name arena. The reply carries `directory`, the request `id`, `ok`, `error`,
109
+ and `matches:[{"path":"/directory/selected.txt","index":42}]`. Missing paths are omitted,
110
+ duplicates produce one match, and results follow listing order. No unrequested path is returned.
111
+ Without `menuId`, this has the same name-only semantics as the single-path form. With `menuId`,
112
+ each matched survivor must still have the device, inode and file type captured by that completed
113
+ permanent deletion. This explicitly requested survivor check stats only matched paths.
114
+ The backend retains one completed deletion's identities for this read-only purpose even after
115
+ `menuaction/close` has expired the mutable selection. An expired restoration identity returns
116
+ `ok:false` and no matches. Clients must also reject replies after navigation, selection changes,
117
+ or replacement of the pending request, and fetch metadata only for the visible window.
118
+
119
+ ### menuaction identity and deletion
120
+
121
+ `{"c":"menuaction","op":"activate","id":4,"action":"duplicate"}` revalidates the
122
+ selection captured by `snapshot` before the GUI dispatches a selected-item menu action.
123
+ The response echoes `id`, `op`, and `action`, with `ok` and `error`. Captured selection `paths`
124
+ are returned for `validate`, and for `activate` when the action needs resolved paths for client-side
125
+ dispatch; other activations return an empty array. `taildrop:*` and `sharelink` return the captured
126
+ cursor path instead.
127
+ The client also rechecks its listing identity and current action eligibility before dispatch.
128
+
129
+ An optional `menuId` on `transfer`, `trash`, `duplicate`, `rename`, `archive`, or `convert`
130
+ binds that operation to the captured selection. An expired selection, replacement object,
131
+ or uncaptured requested source is refused. Transfer uses the captured sources directly;
132
+ Trash, Duplicate, Archive, and Convert require the entire requested source set to match.
133
+ Identity checks compare the device, inode and file type at the worker boundary; Trash checks again immediately before its existing GIO
134
+ batch handoff. These checks do not make subsequent external-helper filesystem operations
135
+ atomic. Omitting `menuId` preserves the existing keyboard and protocol entry paths.
136
+
137
+ `{"c":"menuaction","op":"snapshot","id":4,"rows":[0,2]}` captures the selected identities
138
+ from the active listing. A Columns neighbour menu instead sends explicit absolute `paths` and
139
+ `cursorPath`, without navigating or changing that listing. Both forms accept the active `listing`
140
+ stamp and return `count`, `modes` (complete mode bits), and `kinds`, an array of each captured item's own file
141
+ type bits in request order, including symlinks without following them. `prepareDelete` with that
142
+ `id` reviews the captured trees and returns a
143
+ fresh `token`, selected `count`, and total `bytes`. `checkDelete` with the `id` and `token` returns
144
+ `valid` without mutating files. A changed set requires `refreshDelete`: it reviews only the same
145
+ captured paths, drops missing paths, captures replacement identities, and issues a fresh token.
146
+ Unrelated paths cannot widen that confirmation.
147
+ `menuaction` operations `tagstates` (with a client `tagId`) and `settags` (with `change` and `tag`)
148
+ read and update the identities retained by `id`. They return the existing `tagstates` and `settagged`
149
+ replies. A changed or replaced source refuses before writing; successful writes retain tag undo.
150
+ The client shows this new confirmation with
151
+ Cancel initially focused; no prior destructive activation authorizes the replacement token.
152
+
153
+ `{"c":"menuaction","op":"delete","id":4,"token":12}` consumes that exact token and reserves
154
+ the ordinary mutation slot. A failed preflight returns `stale:true` and requires a fresh review;
155
+ it does not delete any item. A completed attempt returns `deleted`, `failed`, `cancelled`,
156
+ `error`, and `remaining` absolute paths whose original identities survive. Results report partial
157
+ completion rather than implying rollback. The caller can use the identity-checked batched
158
+ `locate` form to select survivors after refreshing the listing.
159
+ `close` expires the token and cancels pending work. An already claimed deletion root completes
160
+ or restores its survivors before cancellation stops the next root. The undo journal does not
161
+ cover permanent deletion.
162
+
163
+ `{"c":"menuaction","op":"properties","id":4}` describes the identities captured by `snapshot`.
164
+ For one item the existing single-item reply is unchanged. For several items it returns `kinds`,
165
+ combined `bytes`, and `newest` and `oldest`, the selected entries' own modification dates in local
166
+ time as `YYYY-MM-DD HH:MM`. Kinds are counted in this order, omitting empty groups: folders,
167
+ symbolic links, texts, images, videos, audio files, files and special files. Regular files use the MIME
168
+ name database's text, image, video and audio groups, with all other regular files grouped as files.
169
+ Counts use singular words for one and comma grouping for thousands, for example `2 images, 1 video`.
170
+ No file contents are opened to classify them, and a symbolic link keeps its own kind, size and date.
171
+
172
+ A selection containing folders first answers its known file bytes with `done:false`, then walks
173
+ each selected folder on demand using the same walker as `contents`. A selection on a remote mount
174
+ or containing a remote mountpoint has one 15-second bound covering initial metadata, every walk
175
+ and final identity revalidation; cancellation frees the menu worker without waiting for a blocked
176
+ remote syscall. A deadline ends with the retained floor, `done:true,partial:true`. A worker lost
177
+ before a terminal reply returns `ok:false,error:"The selected items stopped answering."`.
178
+ Before initial metadata completes, `kinds`, `newest` and `oldest` are empty and `bytes` includes
179
+ only inspected non-folder entries. A silent syscall marks its current remote mount stuck; a
180
+ subsequent request involving that mount returns a zero partial floor before any stat. Each total
181
+ includes the selected folders' own entries, stays on each starting device and never follows links.
182
+ Each queued child opens without following links through a held directory descriptor and must
183
+ still match its captured identity. A replaced or unreadable descendant leaves a partial floor.
184
+ Progress and the final reply have the same shape:
185
+ `{"t":"menuaction","id":4,"op":"properties","ok":true,"kinds":"2 images, 1 video","bytes":52300000,"newest":"2026-09-23 10:14","oldest":"2026-08-21 12:02","partial":false,"done":true}`.
186
+ `done:true` means the request ended, and only `done:true,partial:false` means the total is exact.
187
+ Unreadable or vanished descendants and already-stuck mounts leave a floor with `partial:true`.
188
+ The overall remote deadline also leaves a partial floor. `close` or a newer `snapshot` cancels the request, whose last reply carries
189
+ `cancelled:true,partial:true,done:false`; a cancellation reply may omit the facts.
190
+ After a close, the caller discards all replies for that snapshot. A moved, vanished or replaced
191
+ selected identity returns `ok:false` with an `error`, including when it changes during the walk.
192
+ Dates and Kinds describe selected entries, not their descendants. Selected roots are summed
193
+ independently, so selecting both a folder and an entry inside it counts that entry in each root.
194
+
195
+ `{"c":"menuaction","op":"contents","id":4}` counts what the one captured folder holds, for Properties on a folder.
196
+ It walks with the folder size walker and never crosses into a nested mount or follows a symbolic link. A local folder
197
+ walks on the menu worker with no deadline. A folder on a remote mount, or holding one anywhere below it (the walk
198
+ still reads the mount point's own entry), walks on a worker beside the menu worker under the bulk deadline (15 s): past
199
+ it the reply is the floor counted so far, `partial:true` and `done:true`, the walk stops, and the remote mounts it
200
+ could touch are marked stuck for 30 s. While one is marked, the same request answers the empty floor at once
201
+ (`bytes:0`, `items:0`, `partial:true`, `done:true`) without a walk. `items` counts every entry the walk visits below the folder (files, folders, links and other entries), not the
202
+ folder itself, and `bytes` is the folder size walk's total, which includes the folder's own entry.
203
+ The walk runs under the request's own cancellation, so `close` or a newer `snapshot` stops it within one directory
204
+ read, and a remote one is answered at once. It answers a progress line every 150 ms while it walks, and none for a folder that
205
+ finishes sooner: `{"t":"menuaction","id":4,"op":"contents","ok":true,"bytes":2104332800,"items":812,"partial":false,"done":false}`.
206
+ The last line carries `done:true`. `partial` is true when the walk could not read a directory or one vanished, and the
207
+ numbers are then a floor. A cancelled walk answers once with `cancelled:true` and `"done":false`. An `id` that is not the
208
+ captured snapshot, a selection of several items, or an item that is not a directory (a symbolic link to one is a link)
209
+ answers `ok:false` with an `error`.
210
+
211
+ `{"c":"menuaction","op":"facts","id":4}` reads the codecs, bit rate and photo facts for Properties on a file, for the one captured
212
+ item. Properties sends it once, right after the `properties` reply, for a file that is not a folder or a link, so the card opens on
213
+ what the listing already knows and the new pairs arrive beside it. It answers one line on the menu worker, under the request's own
214
+ cancellation: `{"t":"menuaction","id":4,"op":"facts","ok":true,"vcodec":"h264","acodec":"aac","bps":8100000,"make":"Canon","model":"Canon EOS R6","lens":"RF35mm F1.8 MACRO IS STM","shutter":[1,250],"fnumber":[28,10],"iso":400,"taken":"2026:09:12 18:31:07"}`.
215
+ Every field is raw and a fact the file does not have is `""` or `0` (`[0,0]` for a rational), so the card shows no pair for it.
216
+ `vcodec`, `acodec` and `bps` come from the same `ffprobe` header read `meta` uses (`format=duration,bit_rate` and the streams'
217
+ `codec_name`; the first video and the first audio stream name the file), and only for a video container by extension. The photo
218
+ fields come from the file's own EXIF, read from the head and never decoded: a JPEG's first Exif APP1, a TIFF-based RAW (CR2, NEF,
219
+ ARW, DNG) from its IFD0 and Exif IFD in the first MiB, and a CR3 from the CMT1, CMT2 and CMT3 boxes of its Canon `uuid` box in the
220
+ `moov`. Text is cut to 120 characters with control characters turned to spaces. A cancelled request answers `ok:true` with
221
+ `cancelled:true` and no facts. An `id` that is not the captured snapshot, a selection of several items, or an item that is not a
222
+ regular file answers `ok:false` with an `error`; a fifo or a device is never opened.
223
+
224
+ ### gitstatus
225
+
226
+ `{"c":"gitstatus","path":"/repo","enabled":true,"refresh":false,"id":1}`
227
+
228
+ Reads one repository-wide snapshot for Git decoration without replacing the ordinary listing,
229
+ its row numbering or its watch. `path` is the absolute directory being displayed. `enabled`
230
+ defaults to false and `refresh` to false; `id` defaults to 0 and is echoed on every reply.
231
+ The shared Boolean setting is `showGitStatus`, off by default. Its window control is
232
+ `Show Git status` in Settings, View, immediately before `Highlight today's dates`.
233
+
234
+ An enabled directory change discovers the worktree and runs one read-only Git status. The same
235
+ directory reuses a successful cached snapshot until `refresh:true`; failed loads retry on the next
236
+ enabled status request, with the existing mount quarantine still throttling remote discovery.
237
+ Outside a repository no Git process starts. Disabling clears the cache and cancels active or queued
238
+ older work without discovery or process launch. Navigation also cancels the old directory's work.
239
+ Replies may interleave other backend replies, so clients match both `path` and their current `id`
240
+ before displaying them.
241
+
242
+ The existing `changed` event drives refresh through the client's 400 ms absorbing timer: the
243
+ first notification starts it, later notifications do not restart it, and expiry sends this request
244
+ with `refresh:true`. Keep one status request in flight and preserve later watch debt until its
245
+ reply. The existing non-recursive watch covers the displayed directory, not all repository paths
246
+ or `.git` metadata; another directory change or explicit refresh discovers those other changes.
247
+
248
+ The argv is `/usr/bin/git -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.untrackedCache=false
249
+ -c status.renames=true status --porcelain=v2 -z --branch --untracked-files=all --ignore-submodules=none --`.
250
+ The sandbox changes directory to the repository root; there are no path operands or shell.
251
+ It binds the worktree and linked-worktree metadata read-only, isolates the network, clears the
252
+ environment, and sets `GIT_OPTIONAL_LOCKS=0`, `GIT_CONFIG_NOSYSTEM=1`,
253
+ `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_TERMINAL_PROMPT=0`. Capture, parsing and sorting share one
254
+ five-second deadline and navigation cancellation check. Stdout is bounded at 32 MiB, stderr at
255
+ 64 KiB, with the normal sandbox resource limits. A checked 64 MiB aggregate allocation budget
256
+ covers both full capture buffers and cumulative snapshot capacity requests, including paths,
257
+ rename sources, branch text, change records and the sorted folder index. Growth is charged before
258
+ fallible allocation, including replacement storage and 64 bytes of overhead per allocation;
259
+ released allocations remain charged conservatively. Folder marks use at most two path references
260
+ per change, with no ancestor copies. Over-budget input refuses the whole snapshot with
261
+ `Git status snapshot is too large.`; an allocator refusal reports
262
+ `Git status could not allocate its snapshot.` One ordinary staged deletion (`1 D.` with zero
263
+ index and worktree modes) plus one untracked entry (`?`) at the same path is legitimate: Git
264
+ emits this after untracking a retained file or symlink, recreating a staged-deleted file, or
265
+ untracking a retained gitlink (`? path/`). These records coalesce to one `?` change, counted once
266
+ in the branch caption and Changes, with `deleted:false`; the untracked entry determines the
267
+ folder flag and path spelling. A lone deletion remains `D` with `deleted:true`. A rename's
268
+ source metadata can also name an untracked entry; the destination and source remain two distinct
269
+ changes. Other repeated destinations, repeated records, or groups larger than two refuse the
270
+ whole snapshot with `Git status returned a duplicate path.` Coalescing allocates no additional
271
+ storage and retains all existing budget charges, deadline and cancellation checks. Process
272
+ failures retain stderr. Remote discovery uses the existing mount bound.
273
+
274
+ ### gitrows
275
+
276
+ `{"c":"gitrows","path":"/repo","paths":["/repo/Code","/repo/notes.txt"],"id":2}`
277
+
278
+ Projects the last successful snapshot onto the visible rows only. `path` must match that
279
+ snapshot's directory, and every `paths` entry must be an absolute immediate child. At most
280
+ 400 paths are accepted per request; a larger viewport can use several requests. Results follow
281
+ request order, including clean rows, and perform no per-row stat, MIME lookup or subprocess.
282
+ The window draws the marks in a 14 px column before Size; the TUI uses one cell before the size.
283
+ Both use `M`, `A`, `D` and `?`; the TUI represents a changed folder by yellow `■`.
284
+
285
+ ### gitchanges
286
+
287
+ `{"c":"gitchanges","path":"/repo","start":0,"count":1,"id":3}`
288
+
289
+ Pages every canonical changed path relative to the worktree root, deleted and untracked paths
290
+ included. `start` defaults to 0 and clamps to `total`; `count` defaults to 400 and caps at 400.
291
+ The window opens this virtual listing by clicking the branch caption. The branch becomes the
292
+ foreground current crumb, the original folder path becomes its muted ancestor, and Back or Esc
293
+ returns to that folder. Folder prefixes within changed paths are muted before the leaf name;
294
+ the cursor row uses foreground. Deleted rows have no Size or Modified value. There is no added
295
+ `esc returns` hint. Changes view is window only in 0.4.0.
296
+
297
+ This does not replace the ordinary listing or its watch. Changes-view indices never name
298
+ ordinary backend file-operation rows; clients resolve an existing entry against the reply's `root`.
299
+
300
+ ### listpaths
301
+
302
+ `{"c":"listpaths","paths":[<string>,...],"first":<uint>}`
303
+
304
+ Example: `{"c":"listpaths","paths":["/home/gm/Pictures/a.png","/home/gm/Downloads"],"first":80}`
305
+
306
+ Builds a fresh listing out of the paths the client names, replacing the current one, and answers a
307
+ `listed` line followed immediately by a `rows` line covering rows `0..first`, exactly as `list`
308
+ does. The listing's base is `/` and each entry is its absolute path with the leading slash removed,
309
+ so `window`, `thumb`, `paths` and every other per-row facility keep working with no special case
310
+ anywhere; that is the same shape a `search` listing takes, for the same reason. The client splits
311
+ the last `/` itself when it wants to draw a name rather than a path.
312
+
313
+ **Nothing is sorted.** The order the client sent is the order it gets back, because the callers
314
+ are the picker's Recent location and the main window's Recent place, and their order is the
315
+ history's own, newest first; a sort by name would throw that away. `read` on the `listed` line is the time this build took and `sort` is always
316
+ `0.0`.
317
+
318
+ **A path that does not exist is dropped, not listed.** The build `lstat`s each one, so a history
319
+ entry whose file has since been deleted never reaches the client, rather than arriving as a row
320
+ whose `p`, `s` and `m` are all 0. A path that is not absolute, and the root itself, are dropped the
321
+ same way: this list is read out of a file every application on the desktop writes, so it is checked
322
+ here rather than trusted. The type recorded in `d` is the link's own, the same rule `list` follows,
323
+ so a symlink to a directory is listed as a file and a symlink to nothing is still an entry.
324
+
325
+ There is no cancel and no streaming: the build is one `lstat` per path inside the read loop, and the
326
+ one caller sends a few hundred at most.
327
+
328
+ A re-read over the listing already held names added plus removed rows in `changed`, so a reload
329
+ over Recent says how many history entries came or went; a first `listpaths` carries no `changed`.
330
+
331
+ ### window
332
+
333
+ `{"c":"window","start":<uint>,"count":<uint>}`
334
+
335
+ Example: `{"c":"window","start":1200,"count":350}`
336
+
337
+ Stats rows `start..start+count` of the current listing (phase 2) and answers one
338
+ `rows` line. `start` and `count` are clamped to the listing's length, so a stale or
339
+ out-of-range window never panics, it just returns fewer rows or none. Missing fields
340
+ default to `0`.
341
+
342
+ A count of `0` returns an empty `rows` array and `kinds` array with `ms` of `0.000`,
343
+ the clamped `start`, and the current `listing` stamp. It does not read mountinfo,
344
+ stat rows, load row tables, start workers, or rank the remainder of a session's
345
+ matches. A later nonempty window uses the normal complete row format below.
346
+
347
+ ### filter
348
+
349
+ TUI traversal sends `"traverse":true` on the same name-only request and receives
350
+ `{"t":"found","id":7,"query":"^note","rows":[0,2],"error":"","listing":1}`.
351
+ It uses smart-case regular expressions and retains the full listing and selection.
352
+ An invalid pattern returns its diagnostic in `error`. The client accepts only its
353
+ latest id, query and listing generation. Initial traversal includes a matching
354
+ current row; subsequent next/previous steps exclude it and wrap. Its state is
355
+ separate from the live filter.
356
+
357
+
358
+ `{"c":"filter","query":"<string>","id":<uint>}`
359
+
360
+ Example: `{"c":"filter","query":".PNG","id":7}`
361
+
362
+ Matches a case-insensitive literal substring against every name in the current listing,
363
+ including rows outside the client's held window. Missing `query` defaults to the empty
364
+ string and missing `id` to `0`. Filtering reads names only, fetches no metadata and
365
+ does not change the listing, its sort order or its numbering. Each request answers once:
366
+
367
+ `{"t":"filtered","id":7,"query":".PNG","n":1300,"rows":[0,2,4],"ms":0.012,"listing":1}`
368
+
369
+ `n` is the full listing count. `rows` contains matching indices in ascending order in
370
+ the current sorted listing, with `[]` for no matches. An empty query answers `rows:null`,
371
+ so clearing restores the integer-count model without sending an identity array.
372
+ `ms` measures backend matching and serialization, not UI latency.
373
+
374
+ The client accepts a reply only while its `id` and `query` name the latest request, and
375
+ it drops its answer and sends a fresh request whenever a list, sort, search, search
376
+ refresh or final search ranking renumbers the listing, so an accepted reply answers in
377
+ the numbering of the rows on screen, the one its `listing` stamp names. View positions
378
+ map through `rows`; paths and file operations use those original listing indices and
379
+ the listing stamp. Filtering
380
+ does not emit a `rows` metadata reply or refill a window, even on clear. The client
381
+ requests only windows needed for the displayed matches and refills its viewport after clear.
382
+
383
+ ### sort
384
+
385
+ `{"c":"sort","by":"<string>","desc":<bool>,"foldersFirst":<bool>,"groupByKind":<bool>,"hiddenLast":<bool>}`
386
+
387
+ Example: `{"c":"sort","by":"size","desc":true}`
388
+ The TUI retains creation-time, extension and random orders for the running session
389
+ and navigation, without writing them into the shared four-sort settings schema.
390
+ Re-sorts the current listing by `by` and answers a `listed` line. The orders are:
391
+
392
+ - `"btime"` requests optional Linux `statx(STATX_BTIME)` for each entry without
393
+ following symlinks. The returned mask must include `STATX_BTIME`. Unsupported,
394
+ inaccessible or missing timestamps stay unavailable, never substituted with ctime
395
+ or mtime. Like yazi's optional-time comparison, unavailable sorts before known
396
+ times ascending and after them descending; equal times use the name tie-break.
397
+ - `"extension"` reads only existing filename extension bytes, without MIME lookups
398
+ or a metadata pass. Directories retain name order.
399
+ - `"random"` materializes one random rank per entry before comparing and installs
400
+ the resulting permutation for that listing generation. Window reads do not
401
+ reshuffle it and require no per-entry metadata. Both name-only orders report
402
+ `read:0.0`. Existing anchors and listing-generation replacement still apply.
403
+
404
+ - `"name"` works on phase-1 data alone: `read` is `0.0` and `sort` is the sort.
405
+ - `"size"` and `"mtime"` pay the metadata pass first, one `lstat` per row of the whole
406
+ listing split across the cores, then sort. A size sort also walks every folder's subtree
407
+ under one shared 250 ms budget for the whole sort, and that walk is part of the pass,
408
+ so its time lands in `read` too. `read` is that pass in milliseconds and `sort`
409
+ is the sort, so the two costs stay readable apart on the wire.
410
+ - `"kind"` compares MIME type strings obtained from filenames, with
411
+ `application/octet-stream` for an unknown name and `inode/directory` for a folder.
412
+ Files with equal kind order by extension, none first with the Finder comparison, then by name; folders keep name order.
413
+ It does not read file contents or run a metadata pass; `read` is `0.0`.
414
+
415
+ `foldersFirst` defaults to `true`: folders remain before files in both directions.
416
+ With `foldersFirst:false`, the key orders folders and files together. `groupByKind`
417
+ defaults to `false`; when true it takes precedence over `foldersFirst` and fixes
418
+ three groups in this order: folders, images (MIME type starts with `image/`), then
419
+ all remaining files. Groups do not reverse with `desc`. Kind grouping uses filename
420
+ MIME lookup, not content probing. `hiddenLast` defaults to `false`; when true it
421
+ is an outer partition, a visible block then a hidden block, and `foldersFirst`
422
+ or `groupByKind` orders inside each block. Neither block reverses with `desc`.
423
+
424
+ Inside each group, or across the whole listing when ungrouped, the key decides and
425
+ the name order breaks ties, except files with equal kind order by extension first, none first, then by name, so two equal sizes list the same way every run, and
426
+ `desc` is the exact reverse of ascending inside the group,
427
+ tie-break included. A size order walks each folder's subtree and orders folders by that
428
+ recursive size, the same number the `dirsized` line reports, so the order agrees with the
429
+ size column the way it already did for files; an mtime order lists them by time like
430
+ everything else. A folder that hit the shared budget orders by the floor it counted.
431
+ A size sort seeds the answered-row cache only with the folders it finished walking, so a
432
+ later `dirsize` for one of them is answered from the cache rather than walked twice. A folder
433
+ the budget cut off seeds nothing, and is walked again by the worker at its own per-row
434
+ deadline when the client asks, because a floor must not stand in for a size.
435
+
436
+ On a network or phone listing (the classes the client refuses folder sizes on) a size sort walks no folder: each folder
437
+ orders by its own entry size, the floor a walk past its deadline would give, and seeds nothing.
438
+
439
+ **A size order is best effort on a directory large enough to exhaust the 250 ms budget.** A
440
+ folder cut off by it is placed on the floor that was counted, and that floor is used for the
441
+ order alone: it is never sent, so nothing on the wire says the order was built from one. The
442
+ size the column shows for that folder arrives later, from a separate walk at the two second
443
+ per-row deadline, and the two walks carry no ordering relation to each other, so that number
444
+ can be larger or smaller than the floor the row was placed on. Its `dirsized` line carries
445
+ `partial` true only when that second walk ran out as well. On such a directory the column can
446
+ therefore disagree with the order it was sorted into, and a re-sort can still place two
447
+ folders by their floors. A client holding both the order and the later sizes can see that
448
+ disagreement; what it cannot tell from the wire is whether a floor or a changed tree caused
449
+ it. The budget is what keeps the whole pass short while it runs on the event loop, and this is the cost of that
450
+ choice.
451
+ The stat
452
+ is the same `lstat` that `rows` reports `s` and `m` from, so the order always agrees with the
453
+ column, symlinks included, and a row that vanished between the listing and the pass sorts as
454
+ the zeroes `rows` would send for it.
455
+
456
+ **Sort metadata is not retained between requests.** Reversing a size order stats the directory again and walks the folders again, bounded by the same budget each time,
457
+ because a listing in name order must not carry metadata it is not using. The stats
458
+ live for one request. Historical measurements from 2026-09-02, using the default
459
+ name ordering and directory grouping: on the 100,000 file fixture the backend's PSS
460
+ read 4781 kB after the listing and
461
+ 4945 kB after a size sort (one `smaps_rollup` reading each), with a transient peak 3.2 MB
462
+ above that while the pass ran. Measured on that fixture, warm, on 2026-09-02, from the
463
+ `read` and `sort` fields of the `listed` line: the pass took 25 to 38 ms on twelve cores and
464
+ 125 to 135 ms pinned to one with `taskset -c 0`, and the sort behind it 6 to 7 ms against
465
+ 5 ms for name; on a three-row directory the whole request stayed under half a millisecond.
466
+ Cold is IO-bound, and an earlier measurement in the maintainer's notes, not in this repository,
467
+ put the same pass at about 1 s serial and 0.3 s on twelve threads. The pass runs inside the
468
+ loop, so a size or date sort of a very large directory on a slow mount stalls other backend
469
+ requests for its duration. An initial `list` explicitly requesting one of those orders pays
470
+ that same metadata pass.
471
+
472
+ **An unsupported, empty, missing, or non-string `by` answers an `error` line**,
473
+ never a `listed` line in name order. The error names the supplied string key, or an
474
+ empty key when no string was supplied. This prevents a refused request from silently
475
+ resetting the existing order. A refused `sort` leaves the listing in its existing
476
+ order. `sort` never emits a `rows` line on its own;
477
+ follow it with `window` to see the reordered rows. A missing `desc` defaults to `false`.
478
+
479
+ An optional `anchor` names the cursor's logical row for this re-sort as an absolute path,
480
+ `{"c":"sort","by":"size","anchor":"/home/gm/amber"}`, and the `listed` line answers it with
481
+ `"anchor"` echoing that path and `"anchorIndex"` carrying its index in the new order, or `-1`
482
+ when that path is not in the listing. Each reply answers only its own request's anchor, so a
483
+ burst of re-sorts needs no shared generation count: the client moves the cursor only when the
484
+ reply's anchor is still the newest one it sent, and ignores a superseded or foreign one. A
485
+ request without `anchor` answers exactly the `listed` line it always has, so every older client
486
+ keeps parsing what it knows.
487
+
488
+ `mtime` names the same stat field that `rows` carries as `m`; the GUI labels it
489
+ "Modified". `date` is also accepted as an alias for `mtime`, matching the saved
490
+ Settings value. `mode` is not a supported sort key.
491
+
492
+ ### search
493
+
494
+ `{"c":"search","path":"<string>","query":"<string>","hidden":<bool>}`
495
+
496
+ Example: `{"c":"search","path":"/home/gm","query":"dwnhelp","hidden":false}`
497
+
498
+ Walks the whole subtree under `path` and streams every entry whose path relative to `path`
499
+ contains `query` as a case-insensitive subsequence, into a fresh listing that replaces the
500
+ current one. The match is fuzzy rather than a substring, and it runs over the whole relative
501
+ path rather than the base name, so `dwnhelp` finds `downloads/helper.txt`. `hidden` follows
502
+ `list`'s rule exactly: `false`, or missing, drops dot-prefixed names before they are
503
+ counted or descended, so a `.git` costs one `readdir` entry and nothing more.
504
+
505
+ `path` is whatever the client asks for, and the backend walks exactly that and nothing else:
506
+ a client searching a whole home directory sends home as the `path`, and one searching a mount
507
+ sends the mount. The backend has no notion of home and no scope of its own.
508
+
509
+ **Results are ranked, and the rank is what makes a subsequence match usable.** A subsequence
510
+ over a hundred thousand entries matches far too much to read, so every match carries a score
511
+ and the walk answers in score order. Characters matching consecutively score highest; a
512
+ character starting the candidate, a path segment, a word or a camelCase hump scores next; a
513
+ character in the file's own name beats one in a parent directory the query merely passed
514
+ through; and every candidate character skipped between two matches is charged back. Ties go
515
+ to the shorter path, then to the listing's own name order, so one walk over one tree always
516
+ answers in exactly one order.
517
+
518
+ **A search result is a listing like any other, and that is what makes it cheap.** Each match
519
+ is pushed as its path relative to `path`, and `path` becomes the listing's base, so
520
+ `window`, `thumb`, `dirsize` and every other per-row facility keep working with no special
521
+ case anywhere. The client splits the last `/` itself to draw the name and its location
522
+ column; the backend never sends a second shape.
523
+
524
+ Matches are appended in discovery order and never re-sorted mid-walk, so a `window` the
525
+ client already holds stays valid as the count grows. The order changes exactly once, at the
526
+ end: `searched` is written after the rows have been ranked, so a client re-reads its window
527
+ when it sees that line and every row index it held before then is stale. That is the same
528
+ rule a `sort` follows, for the same reason. The walk is answered in three parts:
529
+
530
+ 1. One `listed` with `n` of 0, immediately, because the client's old rows are gone the
531
+ moment the request is read.
532
+ 2. A `searching` line carrying the growing count and the entries scanned so far, at most one
533
+ every 100 ms while the walk runs.
534
+ 3. One terminal `searched` line, written after the ranking.
535
+
536
+ There is no trailing `listed` or `searching`: `searched` carries the final count itself, so a walk that a
537
+ new listing replaced never announces a total it no longer has.
538
+
539
+ The walk runs in bounded slices inside the same single-threaded loop everything else uses,
540
+ four directories per slice, so a `searchcancel` or any other request is never queued behind
541
+ a subtree. A symlink reports its own type, so a link to a directory is listed but never
542
+ descended and no loop is possible. An unreadable directory is skipped in silence, the same
543
+ way `scan` skips an unreadable entry. A `path` that cannot be read at all is not an error:
544
+ the walk finishes at once with `n` and `scanned` both 0.
545
+
546
+ `list` and `sort` both end a running walk before they touch the listing, and answer their
547
+ own lines after the walk's terminal `searched`.
548
+
549
+ **A query may carry `tag:<name>` terms beside the fuzzy text**, `tag:Red holiday` matching files
550
+ that carry the Red tag and fuzzy-match `holiday`. The name matches case-insensitively, and any
551
+ Flea or foreign name is accepted. Every named tag must be present. The attribute is read only on
552
+ files the walk visits, one `getxattr` each, never swept ahead of it.
553
+
554
+ ### searchcancel
555
+
556
+ `{"c":"searchcancel"}`
557
+
558
+ Stops the running walk and answers one `searched` line with `cancelled` true. Whatever the
559
+ walk already found stays in the listing, is ranked the same way a walk that ran to the end is,
560
+ and stays windowable. Unlike `thumbcancel` there is
561
+ no `rows` form: one walk runs at a time, so a cancel can only mean that one. A
562
+ `searchcancel` with no walk running does nothing and answers nothing.
563
+
564
+ With a live session open the walk stops and the session stands: the standing query is
565
+ re-answered once from the partial index, `listed`, `rows`, then `searched` with the query's
566
+ own gen, `walking` false and `cancelled` true, and a later query is still answered from the
567
+ kept index. With no standing query, or an empty one, only that `searched` line answers, for
568
+ the last answered gen. The session never closes on a cancel; `searchclose` is what drops it.
569
+
570
+ ### searchopen
571
+
572
+ `{"c":"searchopen","path":"<string>","hidden":<bool>,"folder":"<string>"}`
573
+
574
+ Opens a live session for the scope `path`. `folder` is the pane's folder the
575
+ session was opened from; scope and folder differ when the scope is home.
576
+ The walk starts at once on a background thread and the current listing is
577
+ untouched: no `listed`, no `rows`, no row invalidation. Answers one line:
578
+
579
+ `{"t":"searchopened","path":"<scope>"}`
580
+
581
+ `hidden` follows `list`'s rule exactly. The scope walks once per session with
582
+ one mountinfo read. Every directory is classified against that snapshot by its
583
+ deepest enclosing mount, including local mounts below remote mounts. Local
584
+ directories use the bounded local walk threads. Network and FUSE directories,
585
+ including ones below a local scope, share one serial remote lane with the existing
586
+ 5 s call deadline and 30 s stuck-mount TTL. A mount refused during a walk is not
587
+ retried by that walk's remaining directories. A remote-root scope walks serially.
588
+ Only one name reader may remain active per remote mount across sessions and
589
+ patches. A reader still blocked after cancel or timeout keeps that slot until
590
+ its syscall returns; another request reports partial coverage instead of starting
591
+ another reader, even if the stuck TTL has elapsed.
592
+ One session runs at a time; opening a second closes the first in silence.
593
+ A `list`, `listpaths`, `sort`,
594
+ `search` or `searchclose` closes the session too, and drops
595
+ its index. Closing mid-walk cancels it: no directory opens after close.
596
+
597
+ ### searchquery
598
+
599
+ `{"c":"searchquery","query":"<string>","gen":<uint>,"count":<uint>[,"sizes":<uint>]}`
600
+
601
+ Asks the open session. With no session open it answers nothing. The query is
602
+ answered from the index with no walk. A `tag:<name>` term keeps the entries that carry that tag, the
603
+ same case-insensitive rule `search` uses; the names are read once per row on a worker
604
+ thread after the walk lands, and until they are in the answer says `walking` true and
605
+ the landing re-runs the query. `gen` is the client's number, newer per
606
+ keystroke; a query whose `gen` is not newer than the last answered one is
607
+ dropped in silence, and a burst answers only its newest query. `count` is the
608
+ first window's size, the same meaning as `window`'s count; missing is the
609
+ list's own first-window default. `sizes` is how many leading rows the client
610
+ draws a folder size for, and 0 asks for none; a query without it keeps the
611
+ last one the session was given, so a burst answered from its newest query
612
+ keeps it too. Each answered query is three lines, in this order, the order
613
+ the listing swap already follows for `list`:
614
+
615
+ 1. One `listed` line with `n` the match count and `path` the scope, carrying the query
616
+ gen beside them, so a line landing after the session closed names no folder.
617
+ 2. One `rows` line for the first window from row 0, ranked, stamped with the
618
+ new generation and carrying the query gen beside it, so held row indices
619
+ invalidate exactly as after a `sort` and a late line lands on no folder.
620
+ With `sizes` above 0 it may end with `"z":[[<row>,<bytes>],...]` after
621
+ `gen`, in row order, the same bytes a whole `dirsized` answer carries for
622
+ each folder row among the first `sizes` rows whose complete size is known.
623
+ 3. One terminal line naming the generation:
624
+
625
+ `{"t":"searched","n":<matches>,"scanned":<entries>,"ms":<ms>,"cancelled":false,"gen":<gen>,"walking":<bool>,"partial":<bool>,"partial_reason":<string>}`
626
+
627
+ `scanned` is the index size the query scored over. `walking` is true while
628
+ the walk is still running: the answer comes from the partial index, and the
629
+ standing query is refreshed from completed directory blocks at most once per
630
+ 100 ms after its initial hold, and once when the walk lands, with its own gen,
631
+ only if no newer query has replaced it; the landing answer has `walking`
632
+ false and `ms` the walk's time. A newer query's answer replaces the window
633
+ whole: no blank listing stands between two queries. Scoring runs on one retained
634
+ session execution worker; the loop alone publishes answers and discards an
635
+ in-flight answer superseded by a newer query or navigation. Ordinary requests
636
+ retain their position relative to collapsed queries. Collapse reports its highest
637
+ drained generation to the execution gate before a buffered readiness event can
638
+ publish an older answer. The published pageable
639
+ remainder survives a discarded answer only while its index row IDs remain valid;
640
+ a pending patch or a recursive watch delta that renumbers the index discards
641
+ that remainder as usual. The newest query rebuilds it. A watch delta that lands
642
+ while an answer is out waits behind it, then is reconciled and re-answered on the
643
+ same worker. Closing releases the execution worker.
644
+
645
+ Requested local rich rows can reuse metadata, MIME and tag results across query
646
+ generations, keyed by unchanged path identity rather than row index. The cache
647
+ clears when a folder answer claims a new generation, when an own write under the
648
+ scope patches the index, and when a change line for the open folder is said; a
649
+ typed query clears it when its requested rows change the watched directories or
650
+ are not covered by the inode watches, and keeps it otherwise. Reuse holds
651
+ at most 350 currently requested rows, with bounded payload and independent
652
+ directory and file-inode watches armed before reading misses. Inode changes,
653
+ including writes through hard links in other directories, invalidate metadata
654
+ and serialized rows together. Ancestor moves and path replacements also
655
+ invalidate reuse. Each watch set holds at most 350 paths; changing the inode set
656
+ briefly retains one old set until its replacement is armed and checked, so
657
+ intersecting identities stay covered. Overflow or failed watch coverage forces
658
+ fresh reads, as do remote paths, symlinks and names requiring lossy display
659
+ conversion. Reuse changes no row fields or response-local kind indices.
660
+
661
+ A query asked while the walk still runs waits for the walk to land, up to
662
+ 150 ms after the first query held, the search hold (`ui/js/Swap.js`
663
+ `HOLD_MS`, not the listing swap's cap `Swap.CAP_MS`, 66 ms), so it is answered once from the whole index; a newer query asked
664
+ meanwhile replaces it and answers in its place. Past `HOLD_MS` the held query
665
+ is answered from the partial index with `walking` true, and every later query
666
+ of that walk is answered at once. A `searchcancel` answers a held query as
667
+ its stop.
668
+
669
+ An empty query keeps the session and its index and answers the open folder's
670
+ own listing, the folder the session was opened from, in its own sort, with
671
+ `listed.path` that folder, in the same listed, rows, searched order with that
672
+ gen. No walk of the scope runs; one readdir of the folder is all it costs. Its
673
+ rows line carries `z` for the folder's own folder rows the same way.
674
+ An unmount of the search scope also completes an empty standing query with one
675
+ `searched` status at that query's generation, `walking:false`, `partial:true`
676
+ and reason `watch_failed`. It preserves the folder's rows, count and listing
677
+ number; it emits no replacement `listed` or `rows` lines.
678
+
679
+ A size is known when the session learned it earlier, from a `dirsize` answer
680
+ given while it is open or from its own walk for an earlier answer, keyed by
681
+ path; or when the walk this answer runs finishes it, all of that answer's
682
+ folders sharing one 3 ms budget. A folder left out of `z` is asked with
683
+ `dirsize` exactly as in any listing. A remote scope never walks for `z`, and a
684
+ scope where a walk that started misses the budget walks for no later answer in
685
+ that session; both still carry the sizes the session knows. A walk the
686
+ scheduler had not started by the deadline marks nothing. Every patch of the
687
+ index, from a write or the open folder's watch, forgets the known sizes.
688
+
689
+ Local sessions own a recursive inotify instance, separate from the pane and column
690
+ watches. Each local directory is armed before enumeration, including directories
691
+ on local USB filesystems. Nested network and FUSE directories are never armed,
692
+ including during own-write patches and subtree replacement. Those directories
693
+ use bounded enumeration and own-write/manual reconciliation, as remote roots do.
694
+ Queued changes are replayed before the completed walk is announced.
695
+ External root and deep create, rename,
696
+ delete, replacement and subtree moves reconcile affected directory blocks without
697
+ another query. The standing nonempty query is then pushed again as `listed`, `rows`,
698
+ `searched`, with the same query `gen` and a new listing generation. Clients replace
699
+ the old rows even when the query generation is unchanged, and discard row indices
700
+ from the old listing generation. Applying an index delta always precedes this push.
701
+ The first idle event has no timer delay; subsequent events already queued share a
702
+ reconciliation. There is no per-query mtime sweep or whole-index repack per event.
703
+ Retained name storage is reclaimed only after retired bytes exceed both 1 MiB and
704
+ one percent of logical storage. Dense row swaps and reclamation invalidate internal
705
+ query caches and any pageable remainder before another answer.
706
+
707
+ The backend's own writes and the open folder's existing watch retain their patch
708
+ paths. A remote-root session uses bounded enumeration and own-write/manual
709
+ reconciliation; it does not claim recursive notification. Closing or cancelling a
710
+ running walk stops and joins the recursive pump and closes its descriptors. A new
711
+ scope rejects queued events carrying the old session's token.
712
+ An unmount event from any recursively watched directory stops the whole scope's
713
+ walk, releases its recursive watches, and retains any usable snapshot with
714
+ `walking:false`, `partial:true` and reason `watch_failed` unless an earlier failure
715
+ already supplies a reason. The scope remains terminally unmounted until reopen:
716
+ queued index patches are discarded, and later own-write or open-directory
717
+ notifications cannot reconcile its retained identities. Explicit user cancellation
718
+ keeps its existing patch behavior. Pending tag reads and retries stop too, with
719
+ the stop flag checked before each new path or chunk and after an in-flight read
720
+ returns, including remote workers and tag rereads. It does not
721
+ scan the exposed mountpoint or automatically resume the scope. Reopen to establish a new
722
+ mount snapshot. A remote read abandoned by timeout or cancellation stops before
723
+ its next entry when a blocked syscall returns; the syscall itself is not killed.
724
+
725
+ The index has no fixed entry-count cap. Raw basenames are interned and paths are
726
+ reconstructed from parent references. Checked ID or arena bounds and allocation
727
+ refusals stop adding entries and preserve identities already held.
728
+
729
+ Every session `searched` line carries `partial` and `partial_reason`. Once
730
+ `walking:false`, `partial:false` and an empty reason certify enumeration without a
731
+ refused directory and no reported local recursive-watch failure. This certifies
732
+ a snapshot, with no automatic remote freshness guarantee.
733
+ While enumeration or tag loading continues, `walking:true` carries `partial:true`
734
+ with reason `walking`, unless an earlier failure supplies a reason. Incomplete
735
+ coverage carries `unreadable`, `capacity` or `allocation`; cancellation carries
736
+ `cancelled:true`, `walking:false`, `partial:true` and reason `cancelled`. Watch
737
+ registration, descriptor or unmount failure carries `watch_failed`. A queue
738
+ overflow reconciles all registered directory blocks and carries `watch_overflow`.
739
+ Both watch failures remain partial until reopen, even if reconciliation recovers
740
+ usable results, so no failed scope claims complete live coverage. For a nonempty
741
+ query, `scanned` counts retained identities even when no entry matches; an empty
742
+ query retains its folder-row count. A patch read refusal also makes coverage partial.
743
+ A partial result remains usable but never certifies a complete live scope inventory.
744
+
745
+ The session has its own lexical scorer; one-shot `search` and folder `jump`
746
+ keep their existing scorer. The query and candidate characters fold without
747
+ Unicode normalization. Exact basenames rank before clean basename prefixes,
748
+ word prefixes, aligned paths, ordinary subsequences and typo-only matches.
749
+ Subsequences use the best run and boundary alignment across the whole relative
750
+ path, including separators, with no first-occurrence limit. ASCII letter
751
+ presence masks reject impossible names before scoring, and an interned basename
752
+ is scored once per query. Unknown and non-ASCII classes bypass this rejection.
753
+
754
+ Typo-only matching starts at a word boundary and permits letter replacement,
755
+ insertion, deletion and adjacent swap, including the first letter. A dropped
756
+ query letter counts only where the matched word ends, so `flea` never matches
757
+ the prefix `fla` of `flac`. Typo-only matches are listed only when no basename
758
+ holds the query exactly, as its prefix or at a word start; with a tag term,
759
+ only the basenames that carry its tags count. The budget
760
+ is zero below four alphanumeric query characters, one from four through seven,
761
+ and two from eight onward. Digits and punctuation are never edited. A query
762
+ beginning with punctuation, such as `.svg`, permits no edits. Explicit path
763
+ queries may correct letters in parent components as well as the basename.
764
+ Fresh lexical ties use shorter relative paths and natural raw identity order.
765
+ The final history and folder-distance order belongs to the read-only ranking
766
+ prior layer; it adds no control or result mark.
767
+
768
+ Only extensions with zero edit budgets on both sides may reuse the previous
769
+ matches, and only with identical tags, complete prior coverage and an unchanged
770
+ index. Backspace, scope or index changes, unfinished tag reads and edit-eligible
771
+ queries rescore candidates. The first window is selected and ordered without a full sort; the rest
772
+ stays scored but unranked until a row past the window is asked for, when the
773
+ next chunk is ordered with the same comparator and appended behind the head,
774
+ the request's need or one window, whichever is larger, at least doubling the
775
+ adopted length, so no window ever shows a row from a different order than the
776
+ one the `listed` line counted. A path lookup and a sort take the whole rest in
777
+ one go, as does any request whose need spans it, select-all among them.
778
+
779
+ ### searchclose
780
+
781
+ `{"c":"searchclose"}`
782
+
783
+ Drops the index and answers one line:
784
+
785
+ `{"t":"searchclosed","path":"<scope>"}`
786
+
787
+ With no session open it answers nothing.
788
+
789
+ ### thumb
790
+
791
+ `{"c":"thumb","rows":[<uint>,...]}`
792
+
793
+ Example: `{"c":"thumb","rows":[2,17,140]}`
794
+
795
+ Asks for a thumbnail for those row indices of the current listing, and answers one
796
+ `thumbed` line per row. **This is the only thing that ever generates a thumbnail.** The
797
+ backend never walks a directory looking for work: a row that no client named is never
798
+ looked at, at any priority.
799
+
800
+ An off storage class still asks, and the backend answers from the cache alone. A client
801
+ on a network, phone or USB directory whose switch is off sends
802
+ `{"c":"thumb","rows":[<uint>,...>],"cacheOnly":true}`, and a genuine cache miss then
803
+ answers an empty `file` at once rather than queueing a decode: one stat of the source
804
+ per visible row and no read, so a NAS folder costs a metadata round trip instead of
805
+ about 48 MB. A row already in the shared cache answers with that entry's path, whether
806
+ or not the source can still be opened, and thumbnails another application already made
807
+ still show. Absent `cacheOnly` is today's full path, so a local folder and an older
808
+ client are unchanged.
809
+
810
+ Each index is clamped to the listing: a row past the end is skipped in silence, because
811
+ there is no row to answer for. A missing or malformed `rows` is an empty array and the
812
+ request does nothing. Indices may repeat and need not be sorted; the newest request for a
813
+ row replaces the older one.
814
+
815
+ Four of the answers cost no work at all and come back on the same line the request
816
+ arrived on. A directory row, a row that is not a regular file, and a row whose MIME type no
817
+ validated thumbnailer declares all answer immediately with an empty `file`; that is the same
818
+ condition the `t` flag in a `rows` object reports, so a client that honours `t` never asks.
819
+ A row already in the shared cache at the file's current mtime answers immediately with that
820
+ entry's path, and a row recorded as failed at that mtime answers immediately with an empty
821
+ `file`. Only a genuine cache miss is queued, and its `thumbed` line arrives later.
822
+
823
+ The queue is bounded, and a request larger than it is still answered in full. When the queue
824
+ is full the oldest job in it is dropped to make room, and the row that job belonged to is
825
+ answered at once with an empty `file` rather than left waiting.
826
+
827
+ **What the shipped client sends.** `ui/Pane.qml` sends `thumb` only when the list settles,
828
+ which is a 120 ms timer restarted by every scroll and by every arriving window of rows, so a
829
+ fling issues nothing at all until it stops. One request names only rows currently visible,
830
+ only rows whose `rows` object carried `t:true`, and only rows the client's own map does not
831
+ already hold.
832
+
833
+ **That map has four states and only two of them are terminal.** Absent means unknown, `null`
834
+ means asked and waiting, a non-empty string is the answer's path, and an empty string is a
835
+ real answer meaning this row has no thumbnail. The two string states are terminal: every
836
+ answer is recorded, an empty `file` included, and an answered row is never asked about again
837
+ while this listing stands. The other two are not. Rows that left the viewport while still
838
+ waiting are named in a `thumbcancel` sent immediately before the same settle's `thumb`, and a
839
+ cancelled row drops back to absent rather than to a terminal state, because the backend
840
+ forgets the job it drops and would otherwise never answer it. **A row that scrolls out while
841
+ waiting and scrolls back is therefore asked for a second time, and that is correct.** A client
842
+ that treats "asked once" as terminal leaves a permanently empty slot on every such row.
843
+ `ui/js/Thumbs.js` is the shipped implementation, its `forget` is the drop back to absent, and
844
+ `tests/js/thumbs.js` holds it under the name "a cancelled row can be asked for again".
845
+
846
+ Neither request is ever sent with an empty `rows`, because that form cancels everything.
847
+ Opening a directory clears the client's whole map, since an index names a different file
848
+ afterwards. See AGENTS.md "Thumbnail requests in the GUI".
849
+
850
+ ### thumbcancel
851
+
852
+ `{"c":"thumbcancel","rows":[<uint>,...]}`
853
+
854
+ Example: `{"c":"thumbcancel","rows":[17,140]}`
855
+
856
+ Drops queued jobs for those rows. An empty or missing `rows` cancels everything queued.
857
+ A job that a worker has already started cannot be cancelled and still answers with a
858
+ `thumbed` line; only work still in the queue is dropped. A cancelled row is not answered,
859
+ so a client that cancels must stop waiting for it. No response line.
860
+
861
+ A cancelled row can be asked for again straight away, in either form of the request: cancelling
862
+ forgets the row as well as its job, so a later `thumb` for it queues fresh work rather than
863
+ being deduplicated against the job that was just dropped.
864
+
865
+ ### fsinfo
866
+
867
+ `{"c":"fsinfo"}`
868
+
869
+ Answers one `fsinfo` line for the directory the current listing came from:
870
+ `{"t":"fsinfo","fs":"<string>","free":<uint>,"path":"<string>","class":"<string>","tagable":<bool>}`.
871
+ `fs` is the filesystem's own name and `free` its bytes available to an unprivileged
872
+ process; `path` is the directory they are of, so a client that has since moved can
873
+ tell they describe somewhere else. `tagable` is that directory's once-per-change probe
874
+ for user extended attributes, so the Tags menu row is absent where the filesystem
875
+ cannot store them. A repeated `fsinfo` for the same directory reuses the answer and makes no
876
+ call. On a `network` or `phone` directory the probe runs on the statfs worker, never the loop:
877
+ the field reads `true` until it lands, and a refusal sends the line again with `false`. `class` is that directory's storage class,
878
+ `network`, `phone`, `usb` or `""` for local, computed once per directory change
879
+ beside this line and never per row: network is cifs, smb3, nfs, sshfs, rclone, 9p,
880
+ ceph and any gvfs FUSE share, phone is gvfs mtp, gphoto2 and afc, and usb is a block
881
+ device whose sysfs path runs through USB or whose removable flag is 1. An unreadable
882
+ path answers an empty `fs` with a free of 0 rather than a wrong number.
883
+ A local directory held by a system mountpoint adds `"system":true` after `class`, and no other line carries the key.
884
+ The mounts are matched by the directory's own device number in `/proc/self/mountinfo`, so a symlink,
885
+ a bind mount or a second mountpoint of one volume answers as the volume does, and a mountpoint counts as a
886
+ system one when it is `/`, `/home`, `/boot`, `/var` or anything else the rail's own list of system mounts names.
887
+ On a slow mount, a gvfs share or a kernel network filesystem, the statfs behind the
888
+ figures is a network round trip, so it runs on a worker thread, one per mount at a time, the
889
+ newest folder asked for going next: a share starts it once its `list` rows are sent, for a client that has asked `fsinfo`
890
+ before, and a kernel mount when `fsinfo` does. The statfs reads the listed folder itself,
891
+ because an sftp host or a phone's storages answer differently below one mount. On a gvfs share
892
+ it reads the folder's first plain file instead when the listing has one: gvfs answers a statfs
893
+ through `smbc_statvfs` on that exact path, and libsmbclient opens a folder for it by listing every
894
+ entry in it, where a file is one open and close. A file gone since the listing falls back to the
895
+ folder. `fsinfo`
896
+ answers at once with the class and that folder's last known figures, or unknown figures
897
+ (`"fs":""`, `"free":0`) on a first visit. When the worker returns, a second `fsinfo` line for
898
+ the same `path` carries the fresh figures, sent only when they differ from what that folder
899
+ last answered and only while it is still the listed directory. A client must accept that line
900
+ at any time: it updates the figures in place and changes nothing else.
901
+ Local and USB directories answer figures and class in the one line, exactly as before.
902
+
903
+ ### dirsize
904
+
905
+ `{"c":"dirsize","rows":[<uint>,...]}`
906
+
907
+ Example: `{"c":"dirsize","rows":[4,9]}`
908
+
909
+ Asks for the recursive apparent size of those row indices, and answers one `dirsized` line
910
+ per row that names a directory. **This is the only thing that ever walks a directory looking
911
+ for size**, the same rule `thumb` follows for thumbnails: a row no client named is never
912
+ walked. A row that is not a directory, or past the end of the listing, is skipped in silence.
913
+
914
+ One persistent background worker walks one directory at a time. The event loop remains
915
+ available for navigation while a walk runs. A list, sort or `dirsizecancel` invalidates
916
+ unfinished size work using a generation counter. Cancelled or stale replies cannot populate
917
+ the current listing's cache. Cancellation and the 2000 ms deadline are checked between
918
+ filesystem calls; a blocked filesystem call may delay further sizes but never holds up the
919
+ navigation event loop. No replacement worker is spawned while the old one is still busy.
920
+ A row already answered for the current listing answers again from its cache; a row already
921
+ queued or running costs nothing extra.
922
+
923
+ **What the shipped client sends.** `ui/List.qml` sends `dirsize` only when the list settles, the
924
+ same 120&nbsp;ms timer `thumb` already waits on, so a fling issues nothing at all. One request
925
+ names only the directory rows currently visible and not already known.
926
+
927
+ ### dirsizecancel
928
+
929
+ `{"c":"dirsizecancel"}`
930
+
931
+ Drops every directory row still queued and cancels the running walk cooperatively. Unlike `thumbcancel`, there is no rows
932
+ form: the walker is one at a time, so a stale row left over from a scrolled-past viewport would
933
+ delay the row the new viewport actually wants, and the client always means "everything" when it
934
+ sends this. A row already answered is untouched; queued and unfinished work is discarded. No response line.
935
+
936
+ A directory a `dirsizecancel` dropped can be asked for again straight away: cancelling forgets
937
+ the row, so a later `dirsize` for it queues fresh work.
938
+
939
+ ### transfer
940
+
941
+ `{"c":"transfer","op":"<string>","paths":["<string>",...],"dest":"<string>"}`
942
+
943
+ Example: `{"c":"transfer","op":"copy","paths":["/home/gm/a.txt","/home/gm/photos"],"dest":"/home/gm/backup"}`
944
+
945
+ Copies or moves each top-level path into `dest`. `op` of `"move"` moves; **anything else, including a
946
+ missing `op`, copies**, so a malformed request can never remove a source. `paths` are absolute; `dest`
947
+ is an absolute directory that must already exist, because Flea does not create a destination as a side
948
+ effect of a transfer. A `dest` that is missing, relative, not a directory, or cannot be written answers a single `error`
949
+ line with `where` of `transfer` and nothing is started.
950
+
951
+ Unlike `thumb` and `dirsize`, this names paths rather than row indices: a transfer outlives the listing
952
+ it was started from, and a row index would name a different file by the time it ran.
953
+
954
+ **A `rows` array of indices into the current listing may be sent instead of `paths`**, because a client
955
+ can only build a path for a row inside the window it holds, and a selection can be wider than that. The
956
+ indices are resolved against the listing at request time and the operation runs on the resulting paths,
957
+ so it still owns a snapshot that outlives whatever the listing does next. `paths` wins when both are
958
+ present, and an index past the end of the listing is dropped in silence.
959
+
960
+ **Copies, moves, archive compressions and extractions queue by destination drive.** Each request
961
+ gets its own id and start line, including a request that waits behind another write to the same drive.
962
+ Waiting jobs run in arrival order and name their predecessor and its target in the jobs `reason`.
963
+ Writes to different drives run together; another running job never causes a busy refusal for these
964
+ requests. An extraction writes to the filesystem holding its destination, just as compression does.
965
+ Ordinary `trash`, `duplicate`, `link` and menu deletion share one non-job operation slot.
966
+ Reviewed Trash deletion also claims that slot and refuses while its previous Trash journal is settling;
967
+ destination-queued transfers do not hold it. Ordinary non-job mutations, including `rename`, `mkdir`
968
+ and `permissionsBatch`, refuse while the slot or an unfinished job is active. `rename` and `mkdir`
969
+ do not claim the slot themselves, and `convert` remains independent.
970
+
971
+ The answer is a `transferstarted` line for a file transfer, then per top-level item a bounded stream of
972
+ `transferprogress` lines and exactly one `transferitem`, then one `transferdone`. An archive `extract`
973
+ uses `extractstarted` for its activity card and `archivedone` for its terminal line.
974
+
975
+ **Semantics that are decided here rather than left to the caller.** A same-filesystem move is a
976
+ `rename(2)`; a cross-filesystem move is a copy followed by removing the source, and the source is only
977
+ removed once the copy is complete, so a process killed mid-move leaves the source intact and a partial
978
+ file at the destination, never the reverse. A symlink is copied as a symlink and never followed. A
979
+ fifo or a socket is recreated at the destination with the source's own mode rather than opened,
980
+ because neither holds contents to stream and an open of one would wait for a writer or fail. A device
981
+ node takes the same path, but creating one needs `CAP_MKNOD`, so an unprivileged copy fails that item
982
+ with `EPERM` instead of recreating it; either way nothing streams from a device that never ends. A
983
+ destination that already exists is refused for that item rather than overwritten, because every write
984
+ here creates its target exclusively, unless the request carries a choice for it, below; even `replace`
985
+ never overwrites, it moves the item already there to the trash and then still creates exclusively.
986
+ Directory recursion is invisible on this wire: the backend walks a
987
+ tree to copy it and the client sees only the top-level item's lines, so the wire's shape does not depend
988
+ on how deep a folder is.
989
+
990
+ **An entry the destination cannot hold never ends the walk of the tree around it.** Inside a folder
991
+ being copied or moved, the backend skips and records an entry the drive refuses for that entry alone,
992
+ and goes on with every other one: a link where `symlink(2)` answers `EOPNOTSUPP` or `ENOSYS`, or `EPERM`
993
+ under vfat or exfat (`this drive cannot hold links`); a fifo, socket or device node where `mknod(2)`
994
+ answers the same (`this drive cannot hold special files`); a name the create or `mkdir` refuses with
995
+ `EINVAL` or `EILSEQ` (the drive's own rule, as `':' is not allowed in a name on this vfat drive`, or `this
996
+ drive does not allow that name`) or `ENAMETOOLONG` (`that name is too long for this drive`); `EEXIST`
997
+ under vfat or exfat beside a source sibling that differs only in case or in trailing dots and spaces, which
998
+ is the drive folding two names into one (`this drive treats that name as the same as another in the
999
+ folder`), while `EEXIST` on any other drive, or with no such sibling, is another writer's and ends the
1000
+ item; and a file of 4 GiB or more onto vfat. A link
1001
+ is never followed in place of the link. Any other failure ends the item. A tree that landed without some
1002
+ entries is a failed item, never a success: its `transferitem` answers `ok:false` with one whole sentence, `<path> could not be
1003
+ copied: <reason>` for one entry, `<n> items inside could not be copied; the first is <path>: <reason>` for
1004
+ more, the path relative to the item and the verb `moved` for a move, whose source stays whole. What
1005
+ landed is journaled as a partial copy with its manifest, so `undo` removes exactly it, and on a durable
1006
+ destination it is still confirmed before `transferdone`. At the top of the selection the same refusal is
1007
+ the item's own failure with the reason as its `err`. Each entry is named on `transferdone`'s `refused`.
1008
+
1009
+ **Copies onto removable, phone and network targets are durable before "done".** When the
1010
+ destination classes usb, phone or network, or its filesystem is vfat, exfat or ntfs, each file is
1011
+ fsync'd after its last write and every directory Flea created or wrote into is fsync'd before the
1012
+ transfer reports done. An rclone mount is the exception: the copy lands in rclone's cache first,
1013
+ so no fsync forces its upload and the done line names the background upload instead. Local btrfs and ext4 targets keep today's behaviour. A failed file fsync fails
1014
+ that item like any other copy error and journals the partial for undo. The same rule covers a
1015
+ same-directory duplicate, a redo of a copy, and a same-filesystem rename onto a durable target:
1016
+ the new files are fsync'd, and the destination directory (and the source directory for a move)
1017
+ with them; a rename that already landed reports success either way, because claiming otherwise
1018
+ would lie about what happened.
1019
+
1020
+ **A choice for names that already exist rides on two optional fields**, `collide` and `collideId`:
1021
+
1022
+ Example: `{"c":"transfer","op":"copy","paths":["/home/gm/Desktop/screenshot.png"],"dest":"/home/gm/Pictures","collide":"replace","collideId":7}`
1023
+
1024
+ `collide` is `"keep"`, `"replace"`, `"skip"`, `"refuse"` or `"merge"`, and `collideId` is the `id` of the
1025
+ `collisions` question it answers. A merge carries one more field, `inner`, the second card's answer
1026
+ for files that exist in both, with the same four words and no `merge` of its own.
1027
+ Example: `{"c":"transfer","op":"copy","paths":["/home/gm/trip"],"dest":"/home/gm/Pictures","collide":"merge","collideId":8,"inner":"keep"}`.
1028
+ A missing `collide` is the transfer exactly as it was before this
1029
+ field existed, so an older client is unchanged; any word but those five refuses, so a malformed choice
1030
+ can never replace. **The choice covers only what the question listed**: an item whose source that
1031
+ question named for this same `dest`, whose name there still holds the same item it held then (device,
1032
+ inode and file type). A name that appears after the question, or whose item was swapped since, is
1033
+ refused exactly as before whatever the choice, and so is every name when `collideId` names no
1034
+ question, another destination, or a question an earlier transfer already spent.
1035
+
1036
+ `keep` lands the incoming item under the name `duplicate` would give it, worked out in `dest`:
1037
+ `photo copy.png`, then `photo copy 2.png`. `skip` leaves the item where it is and counts it in
1038
+ `transferdone`'s `skipped`, with no `transferitem` line. `merge` folds a folder landing on a folder
1039
+ together instead of replacing it whole: everything missing in the destination is copied or moved
1040
+ in, and folders inside merge the same way all the way down; see `mergecollisions`. A folder is
1041
+ merged only while it still holds the item the question saw, and files that exist in both take the
1042
+ transfer's `inner` answer, `keep`, `replace`, `skip` or `refuse`, asked once by that walk before
1043
+ anything is written. No answer ever overwrites without it. `replace` moves the item already there to the
1044
+ freedesktop trash first, through the same `gio` call and URI capture `trash` uses, and then transfers
1045
+ the incoming one under the name; a folder is replaced whole and never merged into. A trash that refuses
1046
+ fails that item with `the item already there could not be moved to Trash, so nothing was replaced` and
1047
+ touches nothing. An item already there that holds any source this transfer names, the item's own or
1048
+ another's, fails with `the item already there holds the one being moved in, so it was not replaced`,
1049
+ because trashing it would take that source along; and an incoming symlink that resolves to the item
1050
+ already there now, or whose own text leads back to that name once the link sits there (looked up the way
1051
+ the kernel would, through any other link on the way), fails with `the incoming link points at the item
1052
+ already there, so it was not replaced`, because the copy would be a link to itself. A lookup that passes
1053
+ 40 links without reaching that name is let through like a dangling link: the kernel refuses it either
1054
+ way, and the item it replaces waits in Trash for `undo`. A replace whose
1055
+ transfer then fails, a cancel included, puts the trashed item straight back when nothing took its name,
1056
+ and otherwise leaves it for `undo`. When that put-back itself fails, the item's `err` gains `; the item
1057
+ it replaced is still in the Trash` and the reason, and the trash step stays in the journal so `undo` can
1058
+ still restore it. A cancel gains it too, as `cancelled; the item it replaced is still in the Trash (<reason>)`,
1059
+ and that item is counted in `failed`, not `skipped`, because its name no longer holds what it held;
1060
+ `transferdone` still says `cancelled`. The trash and the transfer are one journal entry, see `undo`.
1061
+
1062
+ **Any `collide` word also settles an item that already lives in `dest`.** A copy lands under
1063
+ `duplicate`'s name, and a move is left where it is and counted in `skipped`, with no error. Without
1064
+ `collide` both still fail with `already in that folder`.
1065
+
1066
+ ### collisions
1067
+
1068
+ `{"c":"collisions","id":<uint>,"paths":["<string>",...],"dest":"<string>"}`
1069
+
1070
+ Example: `{"c":"collisions","id":7,"paths":["/home/gm/Desktop/screenshot.png","/home/gm/Desktop/notes"],"dest":"/home/gm/Pictures"}`
1071
+
1072
+ Asks, before a `transfer` is sent, which of the items it would name already have their name taken in
1073
+ `dest`, and answers one `collisions` line carrying the same `id`, unless a later `collisions` is sent
1074
+ before it is done, below. `rows` may be sent instead of
1075
+ `paths` and is resolved against the listing exactly as `transfer` resolves it, and a `menuId` asks
1076
+ about that menu's captured selection instead, the one a `transfer` with that `menuId` runs on.
1077
+
1078
+ Example: `{"c":"collisions","id":8,"menuId":31,"dest":"/home/gm/Pictures"}`
1079
+
1080
+ It is read-only and never walks a tree: one `lstat` per source, one per destination name and a resolve
1081
+ of each source's folder. That still measured 0.9 s for 100,000 local sources, and a network mount pays a
1082
+ round trip for each, so the question runs on a thread of its own and the loop keeps answering other
1083
+ requests; the `collisions` line arrives whenever it is done, and a client waits for it before sending
1084
+ the `transfer`. A source that is not absolute, that no longer exists, or that already lives in `dest`
1085
+ is not counted, because the transfer settles those without a question. A `dest` that is missing,
1086
+ relative, not a directory, or cannot be written answers a `total` of 0, and the `transfer` that follows answers its own
1087
+ `error`.
1088
+
1089
+ **The backend keeps the latest question**: each colliding source with the identity of the item its
1090
+ name holds in `dest`, which is what lets a `transfer` naming this `id` in `collideId` apply one choice
1091
+ to exactly those names. It is kept before its `collisions` line is written, so a transfer sent after
1092
+ that line always finds it. The latest question asked is the one kept and the only one answered: an
1093
+ earlier question still being asked when a later one arrives is dropped without a line, the way a result
1094
+ for a superseded listing is, so it is never answered after the later one's; a client asks one question
1095
+ at a time and waits for its line. The next file transfer spends the kept question whether or not it names it. **A `menuId` question also keeps the menu's selection and destination as it saw them**,
1096
+ and a `transfer` carrying that `menuId` and naming this question in `collideId` runs on that capture:
1097
+ Copy to closes its dialog, which sends `menuaction` `close` and expires the live selection, before the
1098
+ answer comes back. The capture holds the same device, inode and type identities the live selection
1099
+ does, and they are still checked per item when the transfer runs. Collision destination identities
1100
+ also compare birth time when the filesystem reports it on both sides. A `menuId` whose selection has
1101
+ already expired answers a `total` of 0, and the `transfer` then answers `Selected items changed; reopen the menu.`.
1102
+
1103
+ **FAT-family destinations are answered inside this round trip.** When `dest` is on `vfat`, `exfat`,
1104
+ `fat`, `fat12`, `fat16`, `fat32`, `msdos`, `ntfs` or `ntfs3` (read off `/proc/self/mountinfo`, never
1105
+ from a name) and the question carries no `fat`, a source whose name that filesystem refuses is answered
1106
+ with one `fatnames` line (below) in place of the `collisions` line. The client asks the same question
1107
+ again with `"fat":"rename"` or `"fat":"skip"` and gets the `collisions` line for the names that will
1108
+ land: Rename and copy asks about the renamed leaves, Skip them leaves the refused names out. Rename and
1109
+ copy also renames, by the same rule, every UTF-8 name the transfer writes inside a folder it copies or merges into, and writes a name that is not UTF-8 as it is;
1110
+ Skip them skips only the listed top-level names and leaves the names inside as they are. A redo of that copy writes the same names. The
1111
+ `transfer` or `link` that follows names the same answer in `fat` beside `collideId`, so the names the
1112
+ card promised are the names the operation takes, and a skipped name never lands at all. Each forbidden
1113
+ character (`: ? * " < > |`) becomes an underscore, trailing dots and spaces are trimmed, and a name of
1114
+ only dots becomes `_`. A `dest` on any other filesystem, or one whose mount cannot be read, never
1115
+ answers `fatnames`, and a `fat` sent to it changes nothing, so nothing is ever renamed on a guess.
1116
+ Without `fat` both requests behave exactly as before, which is every older client. The merge walk
1117
+ takes the answer from the `collisions` question it follows, so under Rename a name inside a merging folder
1118
+ is asked about under the name the transfer writes, and a renamed name that meets an existing one gets the second card's question.
1119
+
1120
+ ### mergecollisions
1121
+
1122
+ `{"c":"mergecollisions","id":<uint>,"paths":["<string>",...],"dest":"<string>"}`
1123
+
1124
+ Example: `{"c":"mergecollisions","id":8,"paths":["/home/gm/trip"],"dest":"/home/gm/Pictures"}`
1125
+
1126
+ Asks, after the first card answered `merge`, which files inside the merging folders already have
1127
+ their name taken in `dest`, and answers one `mergecollisions` line carrying the same `id`. `rows`
1128
+ may be sent instead of `paths` and a `menuId` asks about that menu's captured selection, exactly
1129
+ as `collisions` does, and the walk reuses the first card's capture rather than the live selection
1130
+ its dialog already expired. It is read-only and runs beside the loop like `collisions`: one walk
1131
+ of the sources against the destination, made before anything is written, gathering every relative
1132
+ name both sides hold except folders that merge further down. The transfer that follows names this
1133
+ question in `collideId` and carries the second card's answer in `inner`; a name that appears after
1134
+ the walk, or whose item was swapped since, is refused exactly as before whatever `inner` says.
1135
+
1136
+ ### pasteimage
1137
+
1138
+ `{"c":"pasteimage","dest":"<string>","name":"<string>"}`
1139
+
1140
+ Example: `{"c":"pasteimage","dest":"/home/gm/Pictures","name":"Pasted image 2026-09-23 at 10.14.32.png"}`
1141
+
1142
+ Writes the clipboard's image as a named file in `dest`, the p key with no paths on the
1143
+ clipboard. The bytes never cross this wire: the backend runs `wl-paste --type image/png`
1144
+ itself, refuses an image past 256 MiB and one that never arrives within 30 s, and streams the
1145
+ rest into the destination through an O_EXCL temp published by a no-replace rename, which a FAT
1146
+ destination takes where it takes no hard link, so a name that appeared meanwhile is never replaced.
1147
+ A name already taken lands beside it as `stem (n).ext`; a clipboard holding no image answers an
1148
+ `error` line carrying `"code":"emptyclipboard"`, which the UI words itself; a name that is empty, `.` or `..`, or carries `/`, is refused before anything runs. The answer
1149
+ is the same `made` line a new file gets, and one journal entry named `paste`, so z undoes it; redo
1150
+ refuses it, because a clipboard has no recorded source to replay.
1151
+
1152
+ ### link
1153
+
1154
+ TUI copy requests `paths` with `"hold":true` and a request `id`. Replies echo the id and listing generation,
1155
+ including failed identity captures, so a delayed failure cannot clear a newer copy request.
1156
+ Successful replies include `held`, one source identity per path. The identity stores absolute
1157
+ `path`, decimal-string `dev`, `ino`, `kind`, and `born` (null or a two-string
1158
+ seconds/nanoseconds array), preserving integer precision. Capture runs off the
1159
+ backend loop with the existing mount deadline. The client retains it through
1160
+ navigation and collision confirmation. The link request carries that same `held`
1161
+ array. The link worker checks each source against it before any destination action;
1162
+ a missing or changed source fails without creating its link. Existing clients may
1163
+ omit `held`. Every successful link still records the existing journal step.
1164
+
1165
+
1166
+ `{"c":"link","op":"<string>","paths":["<string>",...],"dest":"<string>"}`
1167
+
1168
+ Example: `{"c":"link","op":"relative","paths":["/home/gm/a.png"],"dest":"/home/gm/Pictures"}`
1169
+
1170
+ Creates one link per top-level path inside `dest`. `op` of `"absolute"` stores
1171
+ the full path, `"hard"` creates a hard link, and anything else, including a
1172
+ missing `op`, links relative from the destination, so a malformed request can
1173
+ never write an absolute path by accident. Like `transfer`, this names paths
1174
+ rather than row indices, and a `rows` array of indices into the current
1175
+ listing may be sent instead of `paths`, resolved the same way; `paths` wins
1176
+ when both are present. A `link` whose `listing` is not the numbering in force
1177
+ is refused with `where` of `stale` before a single index is resolved, the
1178
+ same rule `transfer` follows.
1179
+
1180
+ This runs beside the loop and takes the non-job operation slot:
1181
+ the busy check and the collide answer are decided on the loop, then one
1182
+ thread links each source, replacing through the trash when the card chose
1183
+ it, and the answer is written from its message. A source or the destination
1184
+ on a mount with a pending slow write waits first, the same gate every other
1185
+ write passes. A Replace trashes through
1186
+ gio, which stalls on an unresponsive mount, so that work never runs on the
1187
+ loop's own thread. A `link` arriving while an operation runs
1188
+ answers an `error` line carrying `an operation is already running` and journals nothing. The answer is one `linked` line,
1189
+ `{"t":"linked","ok":<uint>,"failed":<uint>,"skipped":<uint>}`, and one journal
1190
+ entry, so one undo removes every link this request created. `note` rides on that line whenever a link this request
1191
+ made could be neither verified nor removed, plain or replacing, naming each leftover link and its cleanup error as `the link at <path> could not be removed: <reason>`, followed by
1192
+ `; the item it replaced is still in the Trash` where a replaced item was kept, and ahead of that a mixed batch's first
1193
+ failure, so a batch that lands some links still names what the rest failed with. An all-failed batch answers an `error` line with `where` of `link` carrying the first
1194
+ failure with that note appended, naming the failing source as `<source>: <message>`. A source that no longer exists is refused
1195
+ for that item and counts in `failed`, so one missing source never stops the rest. A name that
1196
+ already exists is refused for that item unless the request carries the
1197
+ `collide` and `collideId` choice a `transfer` carries, applied by the same
1198
+ rule: only what the question listed, only while the name still holds the same
1199
+ item. `keep` lands the link under the name `duplicate` would give it, `skip`
1200
+ leaves the item where it is and counts it in `skipped`, and `replace` moves
1201
+ the item already there to the trash first and then links under the name. A
1202
+ hard link across drives is refused with both filesystem names in the
1203
+ sentence, and a hard link to a folder is refused outright. A `dest` that is missing, relative, not a directory,
1204
+ or cannot be written answers a single `error` line with `where` of `link` and nothing is started.
1205
+
1206
+ ### linktarget
1207
+
1208
+ The optional `target` string is the selected link's held readlink value. If the
1209
+ worker reads a different value, it refuses rather than following the replacement.
1210
+ TUI replies must also match the held request id, source path and listing generation.
1211
+
1212
+
1213
+ `{"c":"linktarget","path":"<string>","id":<uint>}`
1214
+
1215
+ Example: `{"c":"linktarget","path":"/home/gm/latest","id":3}`
1216
+
1217
+ Answers one `linktarget` line,
1218
+ `{"t":"linktarget","path":"<string>","directory":"<string>","name":"<string>","id":<uint>}`,
1219
+ naming the folder the symlink's target lives in and the target's own leaf, the
1220
+ same path Show in folder uses. `id` echoes the request's, so a reply landing
1221
+ after the pane navigated elsewhere is answered only when it still names the
1222
+ pending one. `directory` is the target's folder with every
1223
+ symlinked folder and `..` resolved the way the kernel follows them, falling back
1224
+ to the link text's own split when it cannot be resolved, and the line is answered
1225
+ from a thread, as `meta` is. A path that is not a symlink answers an `error`
1226
+ line with `where` of `linktarget` and touches nothing.
1227
+
1228
+ ### permissionsBatch
1229
+
1230
+ `{"c":"permissionsBatch","paths":["<string>",...],"modes":["<string>",...],"id":<uint>}`
1231
+
1232
+ Example: `{"c":"permissionsBatch","paths":["/home/gm/a.txt","/home/gm/b.txt"],"modes":["600","600"],"id":7}`
1233
+
1234
+ Changes the mode of every named path to its own target mode, each following
1235
+ the same three-or-leading-zero-four octal rule the dialog's own `apply`
1236
+ enforces, and answers one `permissions` line with `op` of `applyMany`,
1237
+ `{"t":"permissions","id":7,"op":"applyMany","ok":true,"mode":"0600","error":""}`.
1238
+ One journal entry holds every path the Apply changed, so one undo restores
1239
+ them all. A `permissionsBatch` arriving while an operation runs answers `ok` false with
1240
+ `an operation is already running` and changes nothing. Octal, Owner, Group and the change preview drop out for several
1241
+ items: the grid and Apply are the whole card. A mixed box the operator never
1242
+ touched keeps each file's own bit, because the client sends that file's own
1243
+ target mode rather than one mode for all.
1244
+
1245
+ ### transfercancel
1246
+
1247
+ `{"c":"transfercancel","id":<uint>}`
1248
+
1249
+ Example: `{"c":"transfercancel","id":12}`
1250
+
1251
+ Cancels the waiting or running job named by `id`, including a transfer, archive `extract`, or
1252
+ compression, and does nothing for a stale id. Other jobs keep running or waiting independently.
1253
+ A convert is not a jobs operation and cannot be cancelled by this request. There is no response line
1254
+ of its own: a transfer answers with its own `transferdone` carrying `cancelled` true, and an extract
1255
+ answers its own `archivedone` with `ok` false and an `err` of `cancelled`, including when cancelled
1256
+ before it runs.
1257
+
1258
+ **The item in flight is stopped rather than allowed to finish, and what it had already written is
1259
+ removed: a partial file by `copy_file`, a partly-copied directory by `copy_dir`.** A cancel that waited out a multi-gigabyte copy would not be a cancel, and a half-written file
1260
+ at the destination is not a result anyone asked for. That item is reported `ok:false` with an `err` of
1261
+ `cancelled`; every item not yet started is counted in `skipped`. A cancelled extract stops and reaps
1262
+ its sandboxed child, removes its staging directory and publishes no destination, so the same rule holds
1263
+ there without a partial tree. When the archive tool has not exited ten seconds after the cancel, its work
1264
+ folder is left in place rather than deleted under a live writer, and the `err` names that folder.
1265
+
1266
+ A `quit`, or stdin closing, cancels a running operation the same way and waits for its terminal line
1267
+ before the process exits, so shutting down mid-copy also leaves nothing half-written behind. A
1268
+ quit sets each detached compress and convert flag too and drains them under the one 25 s shutdown
1269
+ budget, so each job's `Work` cleanup runs before the process exits and no `.flea-work-*` folder is
1270
+ left beside the destination. A job whose tool has not exited ten seconds after the cancel keeps its folder in place
1271
+ and its terminal `err` names it; one still running when the budget ends is abandoned with the process.
1272
+
1273
+ ### flashimage
1274
+
1275
+ `{"c":"flashimage","path":"<string>"}`
1276
+
1277
+ Example: `{"c":"flashimage","path":"/home/gm/Downloads/raspios-arm64.img.xz"}`
1278
+
1279
+ Classifies one image and answers one `flashimage` line. The kind comes from the name and is
1280
+ confirmed by magic bytes: `.iso` (ISO 9660 `CD001` at 32769), `.img` and `.raw` (anything),
1281
+ `.img.xz` (`FD 37 7A 58 5A 00`), `.img.gz` (`1F 8B`), `.img.zst` (`28 B5 2F FD`),
1282
+ `.img.bz2` (`BZh`), `.zip` (`PK 03 04`). A name and magic that disagree answer `"kind":""`
1283
+ and the row is refused with "This is not a disk image Flea can flash." The unpacked size is
1284
+ read in the sandbox, never parsed in Flea's own process: `xz --robot --list` for xz,
1285
+ `zstd -lv` for a frame that records its content size, and the archive listing Flea already
1286
+ runs for a zip. gzip's size field wraps at 4 GiB and bzip2 has none, so both answer
1287
+ `known:false`. A zip qualifies only when its listing has exactly one member and that member
1288
+ is an image by the rules above.
1289
+
1290
+ `{"t":"flashimage","path":"<string>","kind":"iso|img|xz|gz|zst|bz2|zip","bytes":<uint>,"unpacked":<uint>,"known":<bool>,"member":"<string>"}`
1291
+
1292
+ `bytes` is the file's own size. `unpacked` is the size that will be written, or 0 when
1293
+ `known` is false. `member` names the zip's one image member and is empty otherwise. A
1294
+ refused row answers `"kind":""` with `unpacked` 0, `known` false and `member` empty.
1295
+
1296
+ ### flashtargets
1297
+
1298
+ `{"c":"flashtargets","image":"<string>"}`
1299
+
1300
+ Example: `{"c":"flashtargets","image":"/home/gm/Downloads/debian-13.iso"}`
1301
+
1302
+ Answers one `flashtargets` line listing every eligible drive in `/sys/block` order, with
1303
+ the refusals the image causes. `image` may be empty, which is a pane opened from a drive:
1304
+ every drive then lists with no `small` refusal. Never lists a drive the safety model
1305
+ excludes; see "The image flasher" in `AGENTS.md`.
1306
+
1307
+ `{"t":"flashtargets","drives":[{"dev":"sda","seq":<uint>,"serial":"<string>","label":"128GB","model":"General USB Flash Disk","bytes":<uint>,"volumes":["128GB"],"refused":""}]}`
1308
+
1309
+ `seq` is the kernel's `diskseq`, which never repeats within a boot, so it names this
1310
+ plugging of this medium. `label` is the rail's name when the drive shows as one rail row,
1311
+ else the model. `model` joins vendor and model the way `omarchy-drive-info` does. `volumes`
1312
+ names the drive's partitions. `refused` is one of `small`, `readonly`, `holdsimage`,
1313
+ `member` (part of a RAID or volume group), or empty when the drive takes the image.
1314
+
1315
+ ### flash
1316
+
1317
+ `{"c":"flash","id":<uint>,"image":"<string>","dev":"<string>","seq":<uint>,"serial":"<string>","bytes":<uint>}`
1318
+
1319
+ Example: `{"c":"flash","id":31,"image":"/home/gm/Downloads/debian-13.iso","dev":"sda","seq":2,"serial":"2003007157171692","bytes":124623257600}`
1320
+
1321
+ Writes the image to the drive the Flash step named and verifies it. The identity fields
1322
+ are the ones the Flash step showed when it was pressed; if any differs when the backend
1323
+ re-reads it, nothing is touched and the answer is `flashdone` with `err` `changed`. A
1324
+ flash is keyed by its own `id` and runs alongside a transfer, because its target is a
1325
+ device no transfer can reach; one flash runs at a time per backend, and a second answers
1326
+ an `error` line with `where` `flash` rather than queueing.
1327
+
1328
+ Answers: one `flashstarted`, a stream of `flashprogress` throttled to one line per
1329
+ 150 ms, the transfer rule, and exactly one `flashdone`.
1330
+
1331
+ `{"t":"flashstarted","id":<uint>,"total":<uint>,"known":<bool>}`
1332
+
1333
+ `total` is the unpacked size when `known` is true and the file's own size otherwise.
1334
+
1335
+ `{"t":"flashprogress","id":<uint>,"phase":"prepare|password|write|flush|verify","bytes":<uint>,"total":<uint>,"permille":<uint>}`
1336
+
1337
+ `bytes` counts bytes the device accepted (write) or read back (verify). `permille` is
1338
+ the share done: bytes over total when the size is known, else the offset of the
1339
+ compressed file the decompressor has consumed over its size. Once the write ends, `total`
1340
+ is the byte count written, so flush and verify measure against it even when the size was
1341
+ unknown. `prepare` runs while the backend re-reads the drive and asks the helper to confirm
1342
+ it, `password` runs from the helper's spawn to its descriptor, `flush` covers the final
1343
+ `fsync`.
1344
+
1345
+ `{"t":"flashdone","id":<uint>,"ok":<bool>,"phase":"<string>","at":<uint>,"err":"<string>","blank":<bool>}`
1346
+
1347
+ `err` rides only on a failure: `cancelled`, `denied` (the prompt was dismissed or
1348
+ refused), `busy` (a volume would not unmount), `changed` (identity differs), `gone`
1349
+ (the device vanished), `io` (a write or read failed), `verify` (read-back differs,
1350
+ `at` is the first differing offset), `nospace` (a stream outgrew the drive),
1351
+ `decompress` (the decompressor failed or stalled), `sandbox` (bwrap or prlimit
1352
+ missing), `helper` (the helper or udisks failed). `blank` says whether the drive's ends
1353
+ were wiped, and is true only when the wipe itself succeeded: a write that ends `cancelled`,
1354
+ `nospace`, `decompress` or `io`, a cancel or quit during the final flush and a `verify`
1355
+ mismatch wipe. `blank` is false where nothing is wiped: `sandbox`, which is found before
1356
+ the first byte moves, every refusal before the descriptor, a drive that is `gone`, and a
1357
+ cancel during verify, which leaves the whole image on the drive and offers Eject alone. A pane that sent an image no probe answers
1358
+ receives `phase` `probe` with `err` `io`; the pane never sends one, because Flash is
1359
+ unavailable until the probe answers a kind.
1360
+
1361
+ ### flashcancel
1362
+
1363
+ `{"c":"flashcancel","id":<uint>}`
1364
+
1365
+ Cancels the flash `id` names and nothing else, as `transfercancel` does. No line of its
1366
+ own; the flash answers its own `flashdone` with `err` `cancelled`. Only Cancel stops a
1367
+ flash: Esc leaves the pane and never cancels one, and a `quit`, or stdin closing,
1368
+ cancels the flash and waits for its `flashdone`, so the drive ends blank rather than
1369
+ half written. A cancel that lands during the verify is the one exception: the image is
1370
+ whole and proven so far, so it stays and `blank` is false.
1371
+
1372
+ ### flasheject
1373
+
1374
+ `{"c":"flasheject","dev":"<string>","seq":<uint>}`
1375
+
1376
+ Example: `{"c":"flasheject","dev":"sda","seq":2}`
1377
+
1378
+ Answers `{"t":"flasheject","ok":<bool>,"err":"busy|gone|changed|error"}`. Unmounts every
1379
+ mounted filesystem on the drive, then powers it off. The message the user reads is the
1380
+ rail's own, so "Ejected 128GB, it is safe to unplug." is the shipped string. `seq` names
1381
+ the plugging the step showed; a drive that changed since answers `changed`. A `dev` that is
1382
+ not a bare kernel name, or a drive the flasher would not list (a system disk), answers
1383
+ `error` before the helper runs.
1384
+
1385
+ ### trash
1386
+
1387
+ `{"c":"trash","id":<uint>,"paths":["<string>",...],"rows":[<uint>,...]}`
1388
+
1389
+ `id` is the client's request id; the `trashed` line that answers it echoes it. Omitted, it is 0.
1390
+
1391
+ Example: `{"c":"trash","id":3,"paths":["/home/gm/old.txt"]}`
1392
+ Example: `{"c":"trash","id":4,"rows":[4,9]}`
1393
+
1394
+ `rows` is the same alternative to `paths` that `transfer` documents above, resolved the same way.
1395
+
1396
+ Moves each path to the freedesktop trash by running `gio trash` under a 10 s deadline, and answers
1397
+ one `trashed` line.
1398
+ **Nothing about the freedesktop trash specification is implemented in this codebase**, only an argv and
1399
+ a result: `gio` already handles the same-filesystem-move-versus-copy question, the `.trashinfo`
1400
+ metadata, and the per-mount `.Trash-$uid` fallback for a volume with no home-relative trash.
1401
+
1402
+ Which paths actually went is read off the filesystem afterwards rather than from `gio`'s exit status,
1403
+ which covers the whole batch and cannot attribute a failure to one path.
1404
+
1405
+ **The trash URI of each item is captured at trash time, and this is a measured requirement rather than a
1406
+ preference.** `gio trash --restore` refuses an original path outright (`Location given doesn't start
1407
+ with trash:///`), and two files trashed from the same path both list that same original, so recovering
1408
+ the URI later is ambiguous. The backend therefore reads `gio trash --list` immediately before and after
1409
+ the call and keeps the entries that are new, which is what makes the operation reversible.
1410
+
1411
+ There is no confirmation step anywhere in this request, because the undo journal is the safety.
1412
+
1413
+ An accepted request first flushes `{"t":"trashstarted","id":<uint>,"n":<uint>,"cancellable":false}`,
1414
+ before the worker touches the Trash or the client re-lists. `n` counts the resolved top-level paths.
1415
+ This acknowledgement lets the client draw its pending operation immediately. It is followed by the
1416
+ existing `trashed` outcome; journal settlement and its ordering are unchanged. Ordinary trashing does
1417
+ not poll a cancel flag, so this acknowledgement must not expose a Cancel action.
1418
+
1419
+ ### trashbrowse
1420
+
1421
+ `{"c":"trashbrowse","op":"prepare","id":<uint>,"all":true,"emptyTrash":true}` reviews the
1422
+ current Trash and answers `{"t":"trashbrowse","op":"prepare","id":<uint>,"ok":true,
1423
+ "token":<uint>,"all":true,"count":<uint>,"bytes":<uint>,"partial":false}`. The confirmation body
1424
+ uses this reviewed count and byte total, including zero bytes for empty files. A partial, pending,
1425
+ or failed review supplies no actionable confirmation. Preparation and validation query item details
1426
+ in bounded groups of 128, with the same complete snapshot and identity checks as individual queries.
1427
+
1428
+ `{"c":"trashbrowse","op":"delete","id":<uint>,"token":<uint>}` deletes that reviewed snapshot.
1429
+ An accepted delete claims the non-job operation slot and immediately flushes
1430
+ `{"t":"trashdeletestarted","id":<uint>,"requestId":<uint>}` followed by a `transferprogress` line,
1431
+ before any queued Trash request, validation, or filesystem work. `id` is the operation id accepted by
1432
+ `transfercancel`; `requestId` is the browser request's id. A busy refusal answers only the ordinary
1433
+ `trashbrowse` error and starts no job. A pending unsettled trash journal also refuses deletion.
1434
+
1435
+ The job uses the operations card's existing progress and terminal types:
1436
+
1437
+ `{"t":"transferprogress","id":<uint>,"index":0,"name":"<string>","itemSize":<uint>,"bytes":<uint>,"total":<uint>,"unit":"items","phase":"checking|deleting","checked":<uint>}`
1438
+
1439
+ `unit:items` changes the meaning of `bytes` and `total` for this operation only: they count deleted
1440
+ and reviewed filesystem entries, including descendants, directories, and zero-byte files. The totals
1441
+ come from the complete saved snapshot, so one trashed folder can report thousands of files. The
1442
+ `trashbrowse` prepare reply's `count` and the terminal item counts remain top-level Trash entries.
1443
+ The immediate sample has total 0 until the worker reads the confirmation; later samples carry the
1444
+ recursive total. During
1445
+ `checking`, `bytes` stays 0 and `checked` counts validated top-level items; during `deleting`, `bytes`
1446
+ counts successfully removed entries, including children of a folder interrupted later. Refused entries
1447
+ do not advance this bar. `name` is the original name of the top-level item in flight and `itemSize`
1448
+ is its reviewed recursive byte size; an empty name means no current item. Samples are throttled to
1449
+ 150 ms, with immediate phase, first-item and final samples. Tree nodes poll cancel and sample progress.
1450
+
1451
+ The native card's head is `Emptying Trash`. Show validation during checking, then the determinate
1452
+ file-count bar and deleted-file count, with the current name and size on its file line. Byte rates
1453
+ cannot be derived from these counts; a remaining-time estimate must use observed file deletions per
1454
+ second and is unavailable during checking. Keep the Trash strip's prior count until completion,
1455
+ then refresh it. The Empty Trash control stays in place and is disabled while the job runs.
1456
+
1457
+ Exactly one `transferdone` releases this job's slot, followed by its original `trashbrowse` delete
1458
+ reply. `ok`, `failed`, and `skipped` on `transferdone` count deleted, refused, and unstarted items;
1459
+ `cancelled` reports interruption. A validation failure carries its sentence in `note`, and the browser
1460
+ reply retains the existing `ok:false`, `stale`, and `error` fields. A completed or interrupted deletion
1461
+ retains `done`, `failed`, and `failures`, and adds `skipped` and `cancelled`. Deletion is irreversible
1462
+ and creates no undo entry. Cancellation during validation preserves the confirmation for another
1463
+ attempt. Once deletion starts it consumes the token, so remaining items require a new review.
1464
+
1465
+ Cancel, quit, and stdin closing poll the same flag between items and between tree nodes. Completed
1466
+ deletions stay deleted. An interrupted folder's surviving payload and `.trashinfo` are put back
1467
+ together; a failed rollback retains the existing recovery record and names its quarantine in the
1468
+ failure. A cancel for another operation id has no effect. The terminal replies precede any client
1469
+ re-list, so displaying progress and completion never waits for that re-list.
1470
+
1471
+ Accepting delete supersedes earlier browser reads and confirmation checks through the browser's
1472
+ request generation. Its independent operation flag accepts cancel while the job is still queued;
1473
+ later browser reads cannot cancel the delete. A superseded check answers its ordinary cancelled error.
1474
+
1475
+ ### rename
1476
+
1477
+ `{"c":"rename","path":"<string>","to":"<string>"}`
1478
+
1479
+ Example: `{"c":"rename","path":"/home/gm/old.txt","to":"new.txt"}`
1480
+
1481
+ Renames one file within its own directory and answers one `renamed` line. `to` is a bare name, not a
1482
+ path: an empty `to`, `.`, `..`, or anything containing `/` or an interior NUL is refused with an `error`
1483
+ line before any syscall runs, because a separator would move the file out of its own directory.
1484
+
1485
+ **The rename refuses to overwrite.** It runs `renameat2` with `RENAME_NOREPLACE` rather than
1486
+ `rename(2)`, which on Unix silently replaces the target; for a file manager that is unrecoverable data
1487
+ loss, and the check-then-rename alternative leaves a window in which another process can create the
1488
+ target. Renaming a file to the name it already has is not an error and is not work: it answers `ok` and
1489
+ records nothing to undo.
1490
+
1491
+ **A rename that cannot prove the source survived whole answers `rename-kept`.** Measured mounts that cannot serve
1492
+ `RENAME_NOREPLACE` include `fuse.rclone` directories and `fuse.megafs` paths, which answer `EINVAL`, and a path under a
1493
+ `/run/user/*/gvfs/dav:` WebDAV mount answers `EIO`. On those, the backend builds the new name through the same exclusive copy primitives every
1494
+ other write uses and removes the source only once that copy is complete. The copy is taken back only on proof the source
1495
+ survived whole: a source that still stats as anything but a directory after the failed removal, since
1496
+ `remove_file` removes every other kind with one unlink that either takes effect or does not. That answers a plain `rename`
1497
+ error, and so does a failure to take the copy back, in which case the copy stays under the new name
1498
+ and that error's `path` is the copy rather than the source. Every other state keeps the copy, a directory source because `remove_dir_all`
1499
+ stops at its first failure, and a source that no longer stats because an errno alone cannot tell a
1500
+ removal that took effect from one that did not. The request then answers an `error` line whose
1501
+ `where` is `rename-kept`, whose `path` is
1502
+ the source, and whose `msg` is the removal's own message; the target is on neither field. Neither
1503
+ direction journals the kept copy, so no `undo` removes it: the removal can stop partway, and one error
1504
+ with no account of how far it got cannot tell a whole source from a remnant or from one already gone. An `undo` reverses a
1505
+ rename through the same call, so a reversal that half succeeds answers this same `where`. A case twin
1506
+ whose move-back from its temp sibling fails answers `rename-stranded` instead: its `path` is the
1507
+ source, its `msg` names the temp leaf and the move-back's cause, and the file stays under that
1508
+ hidden name.
1509
+
1510
+ Unlike the three above, this answers on the loop's own thread on a local mount: the ordinary
1511
+ case is one `renameat2`, which costs less than spawning a thread. On a remote mount it runs on its
1512
+ own worker with the single-call deadline and answers `slow` first, then its `renamed` line when the
1513
+ write lands, journalled exactly as the in-time path would journal it; see `slow`. The compatibility
1514
+ paths above are not one syscall, and on a remote mount they run on that worker too, so a directory
1515
+ rename on rclone or MEGA no longer holds the loop while it copies. See `AGENTS.md`, "Write operations
1516
+ and the undo journal".
1517
+
1518
+ ### bulkrename
1519
+
1520
+ {`"c":"bulkrename","paths":["<string>",...],"names":["<string>",...]`}
1521
+
1522
+ Example: `{"c":"bulkrename","paths":["/home/gm/a.txt","/home/gm/b.txt"],"names":["b.txt","a.txt"]}`
1523
+
1524
+ Renames every named path to its bare new name in the same directory and answers one `bulkrenamed` line,
1525
+ `{"t":"bulkrenamed","ok":<uint>,"unchanged":<uint>}`. The two arrays run in parallel: `names[i]` is the new bare name for `paths[i]`. The whole batch is validated before anything moves: an empty batch, a length mismatch, a non-absolute or repeated path, paths in more than one folder, a missing source or one replaced by another item before the first move, an empty name, `.`, `..`, a name containing `/`, NUL, CR or LF, a name over 255 bytes, two new names that collide, or a destination that already exists outside the batch all refuse the whole batch with an `error` line whose `where` is `bulkrename`, and nothing on disk moves. A destination that is itself one of the batch sources is allowed: swaps and cycles move through temporary names in the same folder, so a to b and b to a works. A name equal to its own source is unchanged rather than work: it counts in `unchanged` and records nothing. The whole batch is one journal entry, so one `undo` reverses every rename it made, newest first; a failure partway journals what landed the same way, so `undo` still reverts the partial batch. Like rename it answers inline on a local mount and on a remote mount runs on its own worker with the single-call deadline, answering `slow` first and its `bulkrenamed` line when the batch lands; see `slow`.
1526
+
1527
+ ### duplicate
1528
+
1529
+ `{"c":"duplicate","path":"<string>"}`
1530
+
1531
+ Example: `{"c":"duplicate","path":"/home/gm/photo.jpg"}`
1532
+
1533
+ Copies one path to a free sibling name and answers one `duplicated` line. The name walks
1534
+ `"<stem> copy<ext>"`, `"<stem> copy 2<ext>"` and so on until one is free; `Path`'s own stem and
1535
+ extension split the last dot only, so `backup.tar.zst` becomes `backup.tar copy.zst` and a dotfile keeps
1536
+ its whole name as the stem. A directory row duplicates its whole tree.
1537
+
1538
+ `duplicate` and `mkdir` also accept an optional positive integer `request`. Without it, or with
1539
+ zero, their existing operation and error lines are unchanged. An opted-in request keeps that
1540
+ payload and adds `request` to its successful `duplicated` or `made` line, followed by one
1541
+ `{"t":"menuaction","op":"creation","id":<request>,"kind":"duplicated|made","ok":true,"path":"<created path>"}`.
1542
+ A refusal instead keeps its original `error` line and adds the same receipt with `ok:false` and
1543
+ `error` in place of `path`. Busy refusals and slow completions carry the exact original request,
1544
+ so concurrent results need not arrive in request order. Correlation changes no operation slot,
1545
+ pending mount claim, journal entry or creation-name rule; independent request fields retain their
1546
+ meaning. Both completion lines are published together before quit can observe a finished slot.
1547
+
1548
+ ### mkdir
1549
+
1550
+ `{"c":"mkdir","path":"<string>","name":"<string>"}`
1551
+
1552
+ Example: `{"c":"mkdir","path":"/home/gm","name":"Invoices"}`
1553
+ Example: `{"c":"mkdir","path":"/home/gm"}`
1554
+
1555
+ Makes one empty directory inside `path` and answers one `made` line. `path` is the absolute parent: a
1556
+ relative or missing `path` is refused before any syscall, because a bare name would otherwise land in
1557
+ the backend's own working directory, which no listing ever names. `name` is a bare name under the rule
1558
+ `rename` applies to `to`: `.`, `..`, anything containing `/` or an interior NUL is refused with an
1559
+ `error` line whose `where` is `mkdir`. A name of only spaces is a legal name and is created as sent,
1560
+ as it would be by `rename`; trimming is the field's job.
1561
+
1562
+ **A missing or empty `name` asks for the first free default name**, `New Folder`, then `New Folder 2`
1563
+ and up, the same way `duplicate` picks ` copy N` itself rather than leaving it to the client: a client
1564
+ holds a window of the listing and not the directory, so it cannot know which names are taken, and a
1565
+ name taken by anything, a file included, is stepped past. That is the form an inline
1566
+ create-then-rename flow sends; the `made` line then carries the name that was chosen. A typed name is
1567
+ never renumbered.
1568
+
1569
+ **The directory is created with `mkdir(2)` and never merged into one already there.** A name already
1570
+ taken by anything, file, directory or symlink, answers `a folder or file with that name already
1571
+ exists` and touches nothing, the same rule `archivework.rs` states for its own staging directories.
1572
+ Every other failure carries `src/error.rs`'s plain sentence as `msg`: a parent that vanished since the listing
1573
+ answers `item not found`, a parent the user cannot write `permission denied`, a read-only mount `the drive
1574
+ is read-only`, a name past `NAME_MAX` `that name is too long`.
1575
+
1576
+ Like `rename`, this answers on the loop's own thread on a local mount and never takes the
1577
+ one-operation slot; on a remote mount it runs on its own worker with the single-call deadline and
1578
+ answers `slow` first, then its `made` line when the write lands; see `slow`.
1579
+
1580
+ ### meta
1581
+
1582
+ `{"c":"meta","row":<uint>,"text":<bool>,"media":<bool>,"archive":<bool>}`
1583
+
1584
+ Example: `{"c":"meta","row":4,"text":false,"media":false,"archive":true}`
1585
+
1586
+ The per-row extras the preview column names and a listing row does not carry, for **one row that a
1587
+ client actually asked for**, never a sweep. `row` indexes the current listing, and a `row` outside it
1588
+ is dropped in silence, so a client that changed directory has to re-ask once the new `rows` arrive.
1589
+ Answers one `meta` line on a thread, because a media row costs an `ffprobe` and the loop waits on it.
1590
+
1591
+ The three booleans are the client's own hints, taken from the classification it already made: `text`
1592
+ asks for a line count, `media` for a duration and sample rate, `archive` for the index. Each costs
1593
+ something, so none is inferred here. A file whose header parses as an image is never counted for
1594
+ lines whatever `text` says, because the newlines in a bitmap are a number nothing should be shown.
1595
+
1596
+ ### shebang
1597
+
1598
+ `{"c":"shebang","path":"<string>","id":<uint>}`
1599
+
1600
+ Example: `{"c":"shebang","path":"/home/gm/run.sh","id":3}`
1601
+
1602
+ The cursor row's two-byte `#!` probe behind the Make executable menu row, for **one path the
1603
+ client named**, never a sweep: only a regular file without its owner execute bit is ever asked
1604
+ about. Answers one `shebang` line, `{"t":"shebang","path":"<string>","hasShebang":<bool>,"id":<uint>}`,
1605
+ on a thread, because an open on a hung mount never returns and the loop waits on nothing. The
1606
+ `id` echoes the request so a later answer never arms an earlier row: a client that asked twice
1607
+ keeps the newest `id` and drops any line naming an older one. A path that is not a regular file,
1608
+ or that cannot be read, answers `hasShebang` false rather than an error, so the row stays absent.
1609
+
1610
+ ### code
1611
+
1612
+ `{"c":"code","token":<uint>,"slot":"<string>","path":"<string>"|"text":"<string>","name":"<string>","lang":"<string>","first":<uint>,"count":<uint>,"raw":<bool>,"csv":"<delimiter>","record":<uint>}`
1613
+
1614
+ Example: `{"c":"code","token":4,"slot":"look","path":"/home/gm/a.rs","first":40,"count":20}` and
1615
+ `{"c":"code","token":5,"slot":"fence:2","text":"fn main() {}","name":"x.rs","lang":"","first":1,"count":10}`
1616
+
1617
+ The syntax-coloured lines `first` to `first + count - 1` of **one file or one text a client
1618
+ actually asked for**, never a sweep. A request holding `text` sends the bytes the client already
1619
+ holds, with `name` picking the language when `lang` is empty (a Markdown info string names it in
1620
+ its first word); without `text` the backend opens `path`, which a client never sends for a file on
1621
+ a network or phone mount. `first` counts from 1 and `count` is capped at 400 lines (the line cap,
1622
+ `lineCap` in `ui/CodeLines.qml`, which asks a longer window in parts), absent meaning the cap. `raw` true skips the colouring and answers the lines plain. Answers one `code` line on a
1623
+ thread. A newer request on the same `slot` ends the older job and the older reply is never sent,
1624
+ so a client matches the `token` of what it last sent and drops any other. At most 1,048,576 bytes
1625
+ of a file are ever read, and `bat` runs in the same read-only jail as the other helpers, on the
1626
+ bytes the verdict was made on.
1627
+
1628
+ A raw table request may carry `csv`: `","` for CSV or `"\t"` for TSV. The backend scans
1629
+ the already loaded bytes as RFC 4180 records, with quotes opening only at a field start,
1630
+ `""` escaping a quote inside quotes and quotes in unquoted fields staying literal. It moves
1631
+ `first` back to the start of the record containing the asked first line, and extends the
1632
+ asked end through its record's last line, or EOF for an unterminated quote. Thus the reply
1633
+ contains whole records and can exceed the 400-line request cap, bounded by the 1 MiB size
1634
+ gate. No second file read or additional stat is made. An absent or invalid `csv`, or one
1635
+ without `raw: true`, keeps the ordinary line window.
1636
+
1637
+ A raw `csv` ask may also carry `record`, a 1-based record ordinal with the header at 1,
1638
+ instead of locating its window by the physical `first` line. The backend locates that
1639
+ record in the same scan and asks `count` physical lines from its start, extending the
1640
+ end through the last record as above. This lets a table jump directly by delegate index
1641
+ without fetching a prefix or receiving a file-wide index. `record` is ignored without a
1642
+ valid raw `csv` delimiter; zero is clamped to 1. A record beyond EOF returns no lines.
1643
+
1644
+ ### html
1645
+
1646
+ `{"c":"html","token":<uint>,"slot":"<string>","path":"<absolute path>","width":<uint>,"height":<uint>,"cancel":<bool>}`
1647
+
1648
+ Example: `{"c":"html","token":3,"slot":"look","path":"/home/gm/index.html","width":984,"height":625}`.
1649
+
1650
+ Renders the single page a client is previewing, never a listing. Width is the frame's device
1651
+ width, clamped to 1 through 4096 pixels. Optional height is the viewport's height at that width;
1652
+ when width is clamped, height scales with it, rounded up and bounded to 1 through 8000 pixels.
1653
+ An omitted height retains the column helper's 600-pixel viewport. The page must be a readable regular file at most
1654
+ 1,048,576 bytes. A newer request in the same slot cancels the older helper and suppresses its
1655
+ reply. `cancel:true` ends that slot without opening a page. HTML and code slots are independent.
1656
+
1657
+ The qml6 helper runs offscreen in its own bwrap namespace with network unshared, a 30-second
1658
+ CPU limit, and a 15-second wall deadline. Chromium needs large virtual reservations, so this
1659
+ helper has no address-space limit. Its profile is off the record, JavaScript is disabled, and
1660
+ navigation to another page is refused. Only the page folder is bound as /page, read-only; a
1661
+ content security policy restricts subresources to that folder and embedded data images/fonts.
1662
+ The one writable bind is a private helper directory under the thumbnail cache. All helper
1663
+ files are created exclusively. The confined input is bound over the page's own filename and
1664
+ loaded from that URL, preserving relative URLs and the document's own base element.
1665
+ The PNG is at least the requested viewport height and at most 8000 pixels tall, and cached by canonical
1666
+ path, size, nanosecond mtime, inode, device, width and viewport height. A hit also requires every
1667
+ engine-recorded local resource to retain its size, nanosecond mtime, inode and device. Records
1668
+ hold at most 256 local resources and 64 KiB; an unreadable or incomplete record forces another render.
1669
+ The companion cache uses the preview-cache bounds
1670
+ of 4096 files and 512 MiB, with a 60-second reader grace and one scan per 32 publications.
1671
+
1672
+ ### compare
1673
+
1674
+ `{"c":"compare","token":<uint>,"a":"<string>","b":"<string>"}`
1675
+
1676
+ Example: `{"c":"compare","token":3,"a":"/home/gm/notes-a.md","b":"/home/gm/notes-b.md"}`
1677
+
1678
+ The unified diff of two regular files a client actually named, never a sweep; `a` is the first of the
1679
+ pair in the listing's order and `b` the second. Absolute paths and readable regular files are checked
1680
+ first; a refusal answers `failed`. Either file over 1,048,576 bytes then answers `over`, judged on both
1681
+ files before reading their contents. The bounded contents of each file are read in pair order: a read
1682
+ failure answers `failed`, growth past the limit answers `over`, a NUL byte in its first 8,192 bytes
1683
+ answers `binary`, and invalid UTF-8 anywhere answers `failed`. Code preview displays invalid UTF-8
1684
+ lossily, but a copied patch must preserve bytes, so Compare refuses it.
1685
+ Otherwise `/usr/bin/diff -u --label <a> --label <b> -- <a> <b>` runs once in the same read-only jail as
1686
+ the other helpers, with the two files bound read-only and nothing writable, so the patch names the real
1687
+ paths. The diff has a 2 s deadline; expiry kills its process tree and answers `failed`. Invalid UTF-8
1688
+ in the diff output also answers `failed`, so the clipboard never receives replacement bytes.
1689
+ Answers one `compare` line on a thread, and `token` echoes the request's so a client matches the
1690
+ one it last sent and drops any other.
1691
+
1692
+ ### pdfcopy
1693
+
1694
+ `{"c":"pdfcopy","id":<uint>,"slot":"<string>","path":"<string>"}`
1695
+
1696
+ Example: `{"c":"pdfcopy","id":3,"slot":"column","path":"/run/media/gm/128GB/doc.pdf"}`
1697
+
1698
+ Fetches one PDF the preview actually opened into a session-private copy under an 8 s deadline, and
1699
+ answers one `pdfcopied` line: `{"t":"pdfcopied","id":3,"path":"<local copy>"}` on success, or
1700
+ `{"t":"pdfcopied","id":3,"err":"that file is not responding","stalled":true}` when the wait
1701
+ runs out. `err` rides only on a failure, and `stalled` only on the wait that ran out, so a client
1702
+ never reads `err` to tell the two apart. The `id` is minted by the one counter in `ui/Backend.qml`,
1703
+ so two viewers never share one; `slot` names the viewer, so a newer fetch supersedes only its own
1704
+ viewer's copy.
1705
+ The copy runs beside the loop, so a dead mount costs the viewer a sentence and
1706
+ never the window; a stale reply is dropped by its `id`, the way a superseded listing's result is.
1707
+
1708
+ ### officepdf
1709
+
1710
+ `{"c":"officepdf","id":<uint>,"path":"<string>"}`
1711
+
1712
+ Example: `{"c":"officepdf","id":4,"path":"/home/gm/Documents/report.docx"}`
1713
+
1714
+ Converts one DOCX, ODT, XLSX, ODS, PPTX or RTF file to a cached PDF with LibreOffice and answers
1715
+ `officepdf` lines carrying the request `id`: `{"t":"officepdf","id":4,"state":"ready","path":"<cached pdf>"}`
1716
+ when the PDF exists, `{"t":"officepdf","id":4,"state":"converting"}` when a conversion started, and
1717
+ `{"t":"officepdf","id":4,"state":"failed","err":"<string>"}` when it cannot be had. A `converting`
1718
+ line is always followed by one `ready` or `failed` line for the same `id` once the job ends, so
1719
+ a client waits for that second line and treats `converting` as progress only. A request that
1720
+ needs no conversion answers `ready` or `failed` at once and sends no follow-up.
1721
+ `unavailable` rides on a `failed` line only when this file or this box will never convert:
1722
+ `{"t":"officepdf","id":4,"state":"failed","err":"LibreOffice is not installed on this box.","unavailable":true}`.
1723
+ A client that reads it keeps its existing preview and does not ask again; a `failed` line without
1724
+ it (a converter error, a timeout, a request replaced by a newer one, a mount that is not
1725
+ responding) may succeed on a later request, except that a converter error (an exit status or no
1726
+ PDF) is remembered for that file until its modification time changes; a timeout is not.
1727
+ The path is resolved and its cache entry looked up beside the loop under the mount deadline, so a
1728
+ hung mount costs one `failed` line and never the window. One conversion runs at a time and the
1729
+ newest request replaces the one queued behind it, whichever request finished its lookup first; a request older than the newest one started, running or already finished, is answered replaced at once and never converts.
1730
+
1731
+ ### officesheets
1732
+
1733
+ `{"c":"officesheets","id":<uint>,"path":"<string>"}`
1734
+
1735
+ Example: `{"c":"officesheets","id":5,"path":"/home/gm/Documents/budget.xlsx"}`
1736
+
1737
+ Converts one XLSX, ODS or XLS workbook to one cached CSV per sheet with LibreOffice's CSV filter
1738
+ (UTF-8, comma separated, values as the spreadsheet shows them) and answers `officesheets` lines
1739
+ carrying the request `id`:
1740
+ `{"t":"officesheets","id":5,"state":"ready","names":["Monthly","Savings"],"paths":["<cached csv>","<cached csv>"]}`
1741
+ lists the sheets in the workbook's own order, each name beside its CSV at the same index.
1742
+ `converting` and `failed` lines, `unavailable`, the remembered converter error, the mount deadline
1743
+ and the one-at-a-time queue are those of `officepdf` above, with `"t":"officesheets"`; the two
1744
+ requests share that queue, and a workbook's PDF and its sheets are separate cache entries.
1745
+ A file that is not a spreadsheet answers
1746
+ `{"t":"officesheets","id":5,"state":"failed","err":"that file is not a spreadsheet","unavailable":true}`.
1747
+ The converter runs once over the whole workbook, then once per sheet to fix the order, skipping
1748
+ the last sheet, which is the one left; a workbook with more than 24 sheets lists its first 24, one
1749
+ order run each.
1750
+
1751
+ ### settags
1752
+
1753
+ `{"c":"settags","rows":[<uint>,...],"op":"<string>","tag":"<string>","listing":<uint>}`
1754
+
1755
+ Example: `{"c":"settags","rows":[2,17],"op":"toggle","tag":"Red","listing":7}`
1756
+
1757
+ Adds, removes or toggles one tag across those rows of the current listing, or clears all tags
1758
+ with `"op":"clear"`, and answers one `settagged` line. A `toggle` is one decision for every row it
1759
+ names, made from the attributes read before the first write: where every row already carries the
1760
+ tag it is removed from all of them, otherwise it is added to all of them, so a set where only some
1761
+ rows carry it never loses the tag from those rows and never ends half toggled. Flea's seven names are `Red`, `Orange`,
1762
+ `Yellow`, `Green`, `Blue`, `Purple` and `Gray`, matched case-insensitively; a name Flea does
1763
+ not know is kept untouched, never rewritten and never dropped. **Every file or none**: a
1764
+ filesystem without user extended attributes refuses with an `error` line naming its filesystem
1765
+ before the first write, each filesystem the rows sit on asked once, and a file that vanished or
1766
+ a write that failed refuses all with exactly the files already written put back, so nothing is
1767
+ half-applied. A row that is neither a file nor a folder is skipped and named in the settagged answer's skipped array while the other rows are still written, so only a real write failure rolls anything back. The change is one undo journal step per file it changed, restoring that file's
1768
+ previous attribute exactly, absent included, and only onto the same file (its device and inode)
1769
+ still holding the attribute the change wrote: a file replaced or retagged since is left in place
1770
+ and named in the `error`, while the other files are restored. Redo writes only onto the attribute
1771
+ undo left: a file retagged since is skipped and named, and a file replaced since refuses the redo. A `settags` is refused with `an operation is already running` while a
1772
+ `transfer`, `trash`, `duplicate` or `link` holds the slot, though a local `rename` is not. Rows on local mounts
1773
+ are written on the loop; when any row sits on a remote mount the write runs on a worker, and past
1774
+ the single-call deadline it answers `slow` first and its `settagged` or `error` line later, the
1775
+ same way a remote `rename` does.
1776
+
1777
+ ### tagstates
1778
+
1779
+ `{"c":"tagstates","rows":[<uint>,...],"id":<uint>,"listing":<uint>}`
1780
+
1781
+ Example: `{"c":"tagstates","rows":[0,4000],"id":3,"listing":7}`
1782
+
1783
+ Reads which of Flea's seven tags those rows of the current listing carry and answers one
1784
+ `tagstates` line with the same `id`. It reads only, so a stale `listing` is not refused; the answer
1785
+ names the numbering its rows resolved in instead. The shipped client sends it when a menu opens over
1786
+ a selection that reaches past the held window, whose rows it holds no tags for, and draws the Tags
1787
+ flyout's checked and mixed marks from the answer. The read runs on its own thread, the way `shebang`
1788
+ does, because an attribute read on a hung mount never returns; a file that cannot be read counts as
1789
+ carrying no tag.
1790
+
1791
+ ### undo
1792
+
1793
+ `{"c":"undo"}`
1794
+
1795
+ Reverses the most recent completed operation and answers one `undone` line naming which kind it was.
1796
+ An empty journal answers an `error` line with `where` of `undo`, and so does a reversal that fails
1797
+ removing what an operation created or restoring from the trash. A reversal that renames back goes
1798
+ through the same call a `rename` does, so its failure answers `rename`, `rename-kept` or `rename-stranded` instead.
1799
+
1800
+ **The journal is the session journal under $XDG_RUNTIME_DIR/flea**, newest entry from any window, surviving a backend restart but not the session, with an in-memory fallback when the dir is refused. An entry too big for the file stores a small barrier, its op name only and no paths, and no window keeps the payload: the undo that claims it answers `That operation was too large to undo.`, the next undo continues with older entries, and a barrier never enters redo. A file written by a newer version is unavailable, never rewritten: the reader falls back to memory. Each kind reverses as follows: a rename or a move renames back (still
1801
+ refusing to clobber, because something may occupy the old name by now), a copy or a duplicate removes
1802
+ what that operation created, a merge is undone only in the window that made it and elsewhere refuses
1803
+ rather than falling back to the whole folder, a link removes only the link it made, and a trash restores through
1804
+ `gio trash --restore` using the URI captured
1805
+ when it was trashed. A hard link whose source is gone or was replaced stays as the last name holding
1806
+ those bytes. A transfer that replaced an item reverses both halves in that one step, newest
1807
+ first: the incoming item is removed or moved back, and then the item it replaced is restored from the
1808
+ trash to its name. A reversal that fails stops the rest and is spent, except a `Mode` step whose file
1809
+ was replaced or whose mode changed since: that step is skipped with a note while the rest restore, and
1810
+ redo skips the same way. So when the incoming half cannot
1811
+ go (a partial folder copy with a file inside newer than its root) or the trash cannot be read back (a
1812
+ `gio` with no `trash://` to list), the replaced item stays in the Trash, restorable from the Trash
1813
+ browser rather than by `undo`. A partial `permissions` undo answers `N items were left in place` (`1 item was left in place` for one) and, when it restored any,
1814
+ names how many it restored for redo, which replays the undone half before anything older. A `mkdir` removes the folder it made only while it is still empty: a folder the
1815
+ user has filled since is theirs, so that reversal answers an `error` line, leaves it and its contents in
1816
+ place, and is spent like any failed reversal, so the next `undo` reaches the operation before it.
1817
+
1818
+ **Only what an operation actually created or moved is recorded**, never a path it merely read, so an
1819
+ undo can never delete a file the operation did not put there. An operation that changed nothing records
1820
+ nothing, and a cancelled batch records only the items that succeeded. An item that failed short of a
1821
+ cancel, and had already created its destination, records that partial destination too: the failure
1822
+ leaves it on disk, because removing it on a transient error would destroy data, and the journal is what
1823
+ lets `undo` remove it. A destination that already existed is never recorded, because nothing was created
1824
+ there. The steps of one operation reverse
1825
+ newest first, and a step that fails stops the rest rather than leaving the operation half-reversed with
1826
+ nothing recording which half, except a `Mode` step, which is skipped with a note while the rest restore.
1827
+
1828
+ ### jump
1829
+
1830
+ `{"c":"jump","id":<uint>,"ranking":<uint>,"favourites":[<string>,...],"recent":[<string>,...]}`
1831
+
1832
+ Example: `{"c":"jump","id":3,"favourites":["/home/gm/Projects"],"recent":["/home/gm/Pictures/screenshots/shot.png"]}`
1833
+
1834
+ A whole ask that follows a provisional one names it: `{"c":"jump","id":4,"ranking":3,"favourites":["/home/gm/Projects"],"recent":["/home/gm/Pictures/screenshots/shot.png"]}`.
1835
+
1836
+ The path bar's folder jump, asked **once per open of the bar**, never per keystroke: the client filters
1837
+ the answer itself as the line changes. `favourites` are Flea's own favourites in their rail order, and
1838
+ `recent` is the desktop's `recently-used.xbel` newest first, which the client reads with Qt's XML reader
1839
+ because the backend has none, and keeps until the file changes. The backend adds the third source,
1840
+ zoxide's ranking, from one `zoxide query --list --all --score`, and answers one `jumped` line from a thread, because zoxide is a subprocess
1841
+ and a stat can block on a network mount. `id` is the client's own number for this open and comes back
1842
+ on the answer, so an answer to an earlier open is told apart from this one's. `ranking` is optional (absent
1843
+ or 0 names none): an open whose recent history is stale asks twice, a provisional ask with what the client
1844
+ has kept and then the whole ask under a new `id`, and the whole ask's `ranking` is the provisional ask's `id`.
1845
+ The backend keeps the zoxide ranking of the newest run that answered in time, with the `id` it answered, and
1846
+ answers a `ranking` naming that id from it with no second zoxide run, so every open runs zoxide exactly once;
1847
+ the cross-source dedup still happens in each answer. A `ranking` naming any other id, or one no run kept
1848
+ (zoxide still running or past its limit, the ranking replaced by a newer ask), runs zoxide as an ask with none does.
1849
+
1850
+ **Nothing is written.** `--all` is what keeps zoxide from pruning its own database on a query Flea made:
1851
+ without it zoxide deletes entries missing for 90 days and saves. zoxide is optional: absent, it is an
1852
+ empty source and says nothing. A zoxide that runs past 2 s is killed and draws nothing, and its answer is
1853
+ read up to 1 MiB and its first 1,000 rows. Only one zoxide runs at a time: an open while an earlier one is
1854
+ still running draws the last ranking that answered in time rather than starting a second, and an
1855
+ open with no ranking kept yet draws no zoxide. Each source is checked on a thread of its
1856
+ own against one shared 1 s budget, so a stat wedged on a dead mount drops that source's rows from that one on
1857
+ and never the other sources. A recent file is checked only once the folder holding it resolved, on that
1858
+ same budget and under that same key rule, so a wedged recent file costs its own row and never the answer. A check still running past the budget of the open that started it is wedged, and
1859
+ the next open skips what it names rather than wedge behind it: its whole mount when that mount is NFS, SMB,
1860
+ 9p, Ceph, AFS or FUSE (read lexically from `/proc/self/mountinfo`, never through the mount), its own path
1861
+ anywhere else. A dead share therefore holds at most one thread per source for good, not more per open, and a
1862
+ check merely slow inside its budget is run again rather than skipped.
1863
+
1864
+ ### devs
1865
+
1866
+ `{"c":"devs","paths":[<string>,...],"id":<uint optional>}`
1867
+
1868
+ Example: `{"c":"devs","paths":["/","/home/gm","/home/gm/Documents"]}`
1869
+
1870
+ The device number of each path named, symlinks followed, so a path reached through a link names the volume the
1871
+ link points at. The device is the number `listed`'s `v` already gives for the listing itself, which is what lets
1872
+ a client compare the two without a table of mounts: a plain drop onto a path moves when its device equals the
1873
+ drag's source device and copies otherwise. The client asks **once per listing** for the ancestors of the listed
1874
+ path, the breadcrumb's drop targets, and **once per drag** for the visible folder rows of the neighbour columns,
1875
+ whose peeked rows carry no device, and keeps the answers by path. Answers one `devs` line from a thread of its
1876
+ own, because a stat on a mount that stopped answering never returns and the loop waits on nothing. A path on a
1877
+ network or FUSE mount runs under the same 5 s bound a listing has, then the mount is marked stuck for 30 s and
1878
+ answers 0 at once, so a dead share costs one thread and one wait, never one per ask. An absent `paths` asks nothing.
1879
+ Rail hover requests include a positive `id`, echoed in the answer, so leaving or changing a row discards its late answer.
1880
+
1881
+ ### clipSet
1882
+
1883
+ `"op":"text"` uses the same queued detached owner with `paths` containing exactly
1884
+ one NUL-free text value. It offers only `text/plain;charset=utf-8`, `text/plain` and
1885
+ `UTF8_STRING`, with no file URI list, copy/cut marker or file token. It never changes
1886
+ the TUI's copied-file selection. TUI copy-as-text joins held selected paths, URIs,
1887
+ parent paths/URIs, filenames or stems with newlines. URI encoding preserves UTF-8
1888
+ bytes and escapes every byte outside the unreserved set and slash.
1889
+
1890
+
1891
+ `{"c":"clipSet","op":"copy"|"cut","paths":[<string>,...]}`
1892
+
1893
+ Owns the system clipboard for those files, so another Flea window (or Nautilus, or
1894
+ Thunar) pastes what this one copied. `paths` are absolute; anything relative, holding a
1895
+ NUL or climbing through `..` is refused with an `ok:false` clip line and nothing is
1896
+ owned. The backend makes a token, spawns a detached `flea --clip-own` holding the same
1897
+ binary, writes it the payload and waits up to 2 s for its `ready`. Runs off the request
1898
+ thread, the way `clipGet` does, so an owner that never answers holds up no other request.
1899
+ Answers one `clip` line with `op` of `set`; see `clip` below.
1900
+
1901
+ ### clipGet
1902
+
1903
+ `{"c":"clipGet"}`
1904
+
1905
+ Reads the current system selection as files. Runs off the request thread, the way other
1906
+ slow reads do, because a foreign source can take up to 2 s to close its pipe. The
1907
+ owner's own token type is read first, then GNOME's copied-files shape, then the
1908
+ uri-list with KDE's cut marker. With no file paths, an `image/png` offer answers
1909
+ `image` without reading its bytes, including when HTML or browser types are offered
1910
+ beside it. An empty or text-only selection answers `none`. Answers
1911
+ one `clip` line with `op` of `get`; see `clip` below.
1912
+
1913
+ ### clipClear
1914
+
1915
+ `{"c":"clipClear","token":<string>}` or `{"c":"clipClear","cut":[<string>,...]}`
1916
+
1917
+ Clears the selection only while it still carries that token, the one `clipSet`
1918
+ answered with or `clipGet` read from another Flea process. A live owner started by
1919
+ this backend is withdrawn first. Otherwise, including when that owner has exited,
1920
+ the token form uses the same token check, queued-selection drain and null selection
1921
+ as `flea --clip clear`, so it can clear a selection another Flea process owns.
1922
+ `cleared:true` means a live owner was signalled or the verified clear sent the null
1923
+ selection. Both clear forms run on the clipboard mutation queue, off the request
1924
+ thread. The `cut` form is a spent cut from another application, which
1925
+ Nautilus and Thunar clear after pasting: it clears only when the current selection is
1926
+ still a cut whose path list equals the given one exactly, same order, and never
1927
+ clears a copy. The verified token clear, `cut` form and `flea --clip clear` check the
1928
+ selection, then drain what the compositor queued behind it with one more round trip,
1929
+ and send the null selection only while the checked offer is still the current one;
1930
+ a newer copy answers `cleared:false`. The protocol has no compare-and-clear request,
1931
+ so a copy made after that last round trip and before the null selection lands can
1932
+ still be wiped. Answers one `clip` line with `op` of `clear`, saying whether it
1933
+ cleared; see `clip` below.
1934
+
1935
+ ### clipWatch
1936
+
1937
+ In flea-qml, the QML frontend sends `{"c":"clipWatch","hostText":true}` to
1938
+ advertise its Quickshell clipboard fallback. If Wayland connection/data-control
1939
+ initialization fails, the backend uses this bridge for the lifetime of that
1940
+ backend. Older callers without the capability keep the ordinary Wayland error.
1941
+ The changed/error notification switches Paste to on-demand reads; no host polling
1942
+ is introduced. A working Wayland connection keeps its existing MIME offers.
1943
+
1944
+ Bridge requests are `{"t":"clipHost","id":N,"op":"read"|"write"|"clear","text":S}`
1945
+ (`text` is absent for read). The frontend answers
1946
+ `{"c":"clipHostReply","id":N,"ok":true,"text":S}` for reads, an empty success
1947
+ for writes, or `cleared:true|false` for compare-and-clear. Failures use `ok:false`
1948
+ and `error`. Replies time out after 15 seconds and late/unknown IDs are ignored.
1949
+ The frontend serializes operations across panes, with fresh reads on Paste.
1950
+ Clear compares the expected text before writing an empty value; external OS
1951
+ writers can still race this check because the OS has no atomic compare-and-clear.
1952
+
1953
+ The text format remains POSIX-quoted paths separated by spaces; cut adds two
1954
+ literal double quotes at **each** end: `""'/path/a' '/path/b'""`. This format is
1955
+ unchanged on Windows and is never PowerShell-escaped. Parsing and path validation
1956
+ remain in the backend. Non-file text returns `none`; the fallback does not add
1957
+ image clipboard support. Browser QS uses awaitable read/write results; native QS
1958
+ uses its standard synchronous `clipboardText` property. Browser QS always tries
1959
+ the host first, but falls back to its last local text on denied or empty reads,
1960
+ matching Land. An external clear is indistinguishable from an Android denied
1961
+ empty read; explicitly writing empty text clears the local cache. Host write
1962
+ failures still report an error even though the local text remains available.
1963
+
1964
+ `{"c":"clipWatch"}`
1965
+
1966
+ Starts the clipboard watcher: one thread holding one data-control connection, reporting
1967
+ every clipboard selection (never the primary one, so a text highlight changes nothing)
1968
+ as files the same way `clipGet` reads them. Idempotent; the UI sends it
1969
+ once at start and later ones answer nothing. No reply of its own: file selections arrive
1970
+ as `clip` lines with `op` of `changed`, a PNG offer without files as `image`, an
1971
+ empty or text-only selection as `none`, and
1972
+ two identical selections in a row emit once. No compositor or manager ends the thread
1973
+ after one `changed` `none` line carrying the error; a dropped connection reconnects at
1974
+ most once a second, at most 5 times.
1975
+
1976
+ Flea's private `application/x-flea-clip` payload is `copy|cut TOKEN PID`, where TOKEN
1977
+ is 32 hex characters and PID is the detached owner's process id. The older
1978
+ `copy|cut TOKEN` payload remains readable but supplies no owner-exit trigger.
1979
+ The two-field form was written only by 0.3.8 development builds, never by a release.
1980
+ Such a build rejects the three-field payload and reads the selection through the GNOME
1981
+ fallback, so the paths and the cut operation still arrive and only the token is lost.
1982
+ Flea 0.3.7 and older read no file selection.
1983
+ Every watching backend, including windows that did not start the owner, holds a
1984
+ pidfd for a reported Flea selection's live owner. A pid already gone at report time
1985
+ (pidfd open returns ESRCH or the pidfd is already readable) triggers the same single
1986
+ guarded fresh read as a later exit. A missing pid, a live process whose executable
1987
+ or arguments do not identify this Flea binary's `--clip-own`, or another pidfd open
1988
+ failure supplies no trigger and no report.
1989
+ The watcher uses one reusable waiter thread and replaces its pidfd wait slot when
1990
+ the reported selection changes; dropping the watching connection closes the slot
1991
+ and stops the waiter. No per-copy thread or descriptor accumulates.
1992
+
1993
+ An owner's exit triggers a fresh connection and the same selection-reading path
1994
+ as `clipGet`. It is never evidence that the clipboard is empty: a clipboard manager
1995
+ may still be re-serving that owner's token. The reading shares the watcher's
1996
+ deduping emitter and is applied only if that owner's token is still the last
1997
+ reported token and no newer selection event has arrived when the reading returns.
1998
+ A watcher offer read captures the report generation when its selection event arrives
1999
+ and is discarded if an exit read begun after that event reports while its bytes are
2000
+ pending, so a delayed offer cannot restore an ended token.
2001
+ Reading the same token emits nothing; a failed reading emits nothing
2002
+ and is not retried. The spawning backend's reaper only reaps, with no separate
2003
+ clipboard notification hook.
2004
+
2005
+ Hyprland gives data-control clients no event when a selection is emptied. The
2006
+ pidfd trigger covers Flea owners; a selection owned by another program and emptied
2007
+ on Hyprland reports nothing until the next selection. Paste always reads fresh,
2008
+ so no file operation can act on that stale watcher state. See `clip` below.
2009
+
2010
+ ### plugins
2011
+
2012
+ `{"c":"plugins","op":"list","id":1}`, and `enable`, `disable`, `update` and `remove` with `"plugin":"<id>"`,
2013
+ `set` with `"plugin"`, `"key"` and `"value"`, and `add` with `"source"` (a Git URL, or an absolute folder or `~/`
2014
+ folder). For `validate`, supply `"source"` as an absolute folder path; it neither clones Git nor expands a literal
2015
+ `~/` prefix. `runtime` takes `"plugin"` and a nested `"request"` object, as described under **plugins runtime**.
2016
+
2017
+ Settings > Plugins and `flea plugin <verb>` use the management operations. Requests are answered on their own
2018
+ threads, so a clone, a pull or a start check does not hold the loop. `list` answers one `plugins` line. The management
2019
+ operations `enable`, `disable`, `set`, `add`, `update`, `remove` and `validate` answer one `pluginop` line and then
2020
+ the `plugins` listing they left, both carrying the request's `id`. Management changes run one at a time. A `runtime`
2021
+ request instead answers one terminal `pluginruntime` line, optionally preceded by `pluginprogress` lines; it does
2022
+ not append a `pluginop` line or a `plugins` listing.
2023
+
2024
+ The listing names the four built-ins first (Shelf, LocalSend, Tailscale, Dropbox), then every folder under
2025
+ `$XDG_CONFIG_HOME/flea/plugins/` whose name does not start with a dot, in name order. Each plugin carries its
2026
+ manifest's facts, `enabled`, `source` (`builtin`, `git` with the origin in the plugin's own `.git` folder, `folder`
2027
+ with the folder it was added from, or empty), its `schema` in the manifest's own shape and its `settings` clamped to that schema. The Shelf also carries
2028
+ `shelf`, the ui.json shelf group and its pins. `failure` names why an enabled plugin cannot run, or why a folder is
2029
+ not a plugin, as a code with one `detail`; it is empty otherwise.
2030
+
2031
+ The Shelf's switch is `shelf.enabled`, and a change of it installs or removes the bar plugin as `--ui-state` does. Every
2032
+ other switch and setting lives in ui.json's `plugins` map, and `set` stores the value clamped. `add` copies or clones
2033
+ into a dot folder first and renames it into place only when its manifest is valid and its id is free, so a failed add
2034
+ leaves nothing; an added plugin arrives disabled. A folder add copies regular files and folders only, skips every dot
2035
+ entry (`.git` included), and refuses a tree past 4096 entries or 64 MB with `write` before anything is written; each
2036
+ file opens without following a link and without blocking, and anything but a regular file refuses the add. A Git add
2037
+ checks its clone against the same 4096 entries and 64 MB, counting every tracked entry, dot entries included, and
2038
+ setting aside only the clone's own top-level `.git`; past either it refuses with `write` before the clone moves into
2039
+ place. The listing reads a Git plugin's `.git/config` only as a regular file of at most 64 KiB, and `update` lists it
2040
+ through Git under a ten second deadline. A folder add records its folder in `.sources/<id>` under the plugins folder, never inside the
2041
+ plugin; the listing reads that record only as a regular file of at most 4 KiB, and `remove` deletes it.
2042
+ `enable` saves the switch and then sends the plugin's executable `hello`; the five second deadline starts before
2043
+ `hello` is written, and no `ready` within it records `start` until the plugin is switched off, updated or starts.
2044
+ `update` is `git pull --ff-only` and reaches only a plugin added from Git. Every Git call runs without system or user
2045
+ config, hooks or fsmonitor, and `update` refuses with `git` a plugin whose `.git` is not a plain folder or whose config
2046
+ holds anything a clone does not write (`core.worktree`, `core.hooksPath` and `core.fsmonitor` among them).
2047
+ `remove` deletes the plugin's folder and its settings; a built-in refuses `remove` and `update` with `builtin`.
2048
+
2049
+ ### plugins runtime
2050
+
2051
+ ```json
2052
+ {"c":"plugins","op":"runtime","id":41,"plugin":"example.send-target","request":{"op":"targets"}}
2053
+ ```
2054
+
2055
+ `plugin` identifies the installed plugin. `id` correlates the outer request with its progress and terminal replies.
2056
+ `request` is the plugin operation object. The outer `op` is always `runtime`; the nested `op` selects the operation.
2057
+ This envelope is accepted by `flea --backend` and the application-wide `flea --plugin-host` process. The latter
2058
+ accepts only `c:"plugins"` requests.
2059
+
2060
+ Except for `cancel`, the plugin must be enabled and have no recorded failure. Ordinary operations must match a kind
2061
+ declared by the manifest. `run` must also name a declared action. A `folder` field requires declared `folder` access
2062
+ and an absolute path without NUL. If supplied, `paths` is an array of at most 512 absolute paths without NUL;
2063
+ `targets` and `section` refuse a `paths` field. An action that declares text input requires the matching one-key
2064
+ `input` object with a nonblank string within its declared character limit and no control characters.
2065
+
2066
+ | Nested `op` | Declared kind | Terminal plugin `op` |
2067
+ | --- | --- | --- |
2068
+ | `targets` | `send-target` | `targets`, with a `targets` array. |
2069
+ | `send` | `send-target` | `done`. |
2070
+ | `run` | `menu-action` | `done`. |
2071
+ | `section` | `rail-section` | `section`, with a `rows` array. |
2072
+ | `list` | `place` | `list`, with an `entries` array. |
2073
+ | `copy`, `move`, `rename`, `delete` | `place` | `done`. |
2074
+ | `preview` | `previewer` | `preview`. |
2075
+ | `shelf` | `shelf` | `done`. |
2076
+ | `settings` | A plugin eligible for a lasting session | `done`. |
2077
+
2078
+ Every ordinary operation may finish with `{"op":"error","message":"..."}` instead. An operation whose normal
2079
+ terminal response is `done` may emit progress before that response. See [PLUGINS.md](../PLUGINS.md) for the
2080
+ operation-specific request and response fields and the shelf routing rules.
2081
+
2082
+ For a preview, the backend envelope names the actual selected local file:
2083
+
2084
+ ```json
2085
+ {"c":"plugins","op":"runtime","id":47,"plugin":"example.torrent-preview","request":{"op":"preview","paths":["/home/me/sample.torrent"],"width":900,"height":600}}
2086
+ ```
2087
+
2088
+ The file must be a regular file matching the previewer's declared MIME patterns. The backend binds that file
2089
+ read-only inside the sandbox and sends the executable `{"op":"preview","path":"/in/file","outDir":"/out","width":900,"height":600}`.
2090
+ The `/in/file` path in the guide's executable exchange is a sandbox path, not the host file to send in the outer
2091
+ request. `width` and `height` accept numbers from 1 through 8,192 and are rounded; each defaults to 900 when absent
2092
+ or invalid.
2093
+
2094
+ Two nested operations are handled by the host rather than forwarded as ordinary plugin requests:
2095
+
2096
+ ```json
2097
+ {"c":"plugins","op":"runtime","id":42,"plugin":"example.send-target","request":{"op":"ready"}}
2098
+ {"c":"plugins","op":"runtime","id":43,"plugin":"example.send-target","request":{"op":"cancel"}}
2099
+ ```
2100
+
2101
+ For an installed executable plugin, `ready` checks startup and returns `message:{"op":"ready"}` on success.
2102
+ Built-ins use in-process adapters rather than the executable handshake. `cancel` stops that installed plugin's
2103
+ lasting session and returns `message:{"op":"done"}`. Cancellation identifies the plugin, not a particular request
2104
+ id. When a lasting subprocess is stopped, Flea attempts to write `{"op":"cancel"}` and immediately kills its
2105
+ process group; the subprocess has no guaranteed cleanup interval.
2106
+
2107
+ The subprocess protocol uses UTF-8 JSON objects separated by newlines. Protocol version 1 is negotiated on that
2108
+ separate stream, rather than on the outer backend envelope. The initial exchange is:
2109
+
2110
+ ```json
2111
+ {"op":"hello","protocolVersion":1,"flea":"0.4.0","language":"en","decimal":".","settings":{},"kinds":["send-target"]}
2112
+ {"op":"ready","protocolVersion":1}
2113
+ ```
2114
+
2115
+ `flea` is the package version; `language` and `decimal` describe Flea's locale. `settings` contains values clamped to
2116
+ the manifest's schema, and `kinds` contains its declared kinds. The startup deadline is five seconds. A `ready`
2117
+ response may omit `protocolVersion`; if present, it must be the number `1`.
2118
+
2119
+ A lasting session handles one call at a time. A concurrent ordinary request is refused with `busy` rather than
2120
+ queued. If the host's `ready` check collides with an active call, its refusal instead uses `start`.
2121
+ Previewer-only plugins do not accept runtime `settings` calls; their fresh startup hello supplies settings.
2122
+ Ordinary lasting-session requests have a 30-second deadline. The subprocess is stopped after 30 seconds idle; a
2123
+ later request starts a new subprocess. Previewers instead run once per request in the thumbnail sandbox, with a
2124
+ 10-second response deadline after readiness.
2125
+
2126
+ Subprocess messages are limited to 65,536 bytes, result arrays to 256 rows, and progress to 128 messages and
2127
+ 1,048,576 aggregate bytes per request. These are subprocess message limits, not the size of a terminal backend
2128
+ envelope that also contains retained progress. Required row strings and `op`, error `message`, progress `message`
2129
+ and preview `png` strings are at most 4,096 bytes and contain no NUL. Row identities (`id`, or `name` for entries)
2130
+ must be nonempty and unique within an answer. Entry names additionally cannot be `.`, `..` or contain `/`.
2131
+ Preview `text` is bounded by the message size rather than the 4,096-byte string limit.
2132
+
2133
+ ### quit
2134
+
2135
+ `{"c":"quit"}`
2136
+
2137
+ Stops the read loop, then answers the thumbnail work already asked for: every queued and
2138
+ running job is drained and reported before the process exits, because a worker inside a
2139
+ child owns a temp file in the shared cache that only its own return publishes or removes.
2140
+ Draining is bounded; see `thumbed` below. Apart from those `thumbed` lines, no response.
2141
+
2142
+ **A queued `dirsize` row is not drained.** It answers nothing that outlives this process, unlike
2143
+ a thumbnail's shared on-disk cache, so a row still waiting when `quit` arrives is simply dropped;
2144
+ the client that asked for it is going away too. A `collisions` question still being asked is dropped the
2145
+ same way, which is why a scripted client waits for its line before sending `quit`.
2146
+
2147
+ ## Responses
2148
+
2149
+ ### gitstatus
2150
+
2151
+ `{"t":"gitstatus","id":1,"path":"/repo","ok":true,"enabled":true,"root":"/repo","branch":"main","label":"main · 8 changed","changed":8}`
2152
+
2153
+ `t` names the request, `id` and `path` echo its correlation, and `ok` reports success. `root`
2154
+ is the canonical worktree root, `branch` its branch name (`detached` for detached HEAD),
2155
+ `changed` the unique changed-path count after coalescing, and `label` the exact branch-caption text derived
2156
+ from that count. An unborn branch keeps its real name; a clean repository says `main · 0 changed`.
2157
+ The normal window's branch caption is muted; in Changes it is foreground. The illustrated
2158
+ `main · 8 changed` count is data, independent of the ordinary listing's item count.
2159
+
2160
+ Disabled replies have `enabled:false`, empty `root`, `branch` and `label`, and `changed:0`.
2161
+ An enabled directory outside a repository has the same empty values with `enabled:true`.
2162
+ Clients hide Git decoration in both cases. A failed status clears stale decoration while
2163
+ ordinary file browsing remains usable. A failed load retries on the next enabled status request;
2164
+ the existing mount quarantine refuses filesystem work until its stuck TTL expires.
2165
+
2166
+ ### gitrows
2167
+
2168
+ `{"t":"gitrows","id":2,"path":"/repo","ok":true,"marks":[{"path":"/repo/Code","status":"","folder":true},{"path":"/repo/notes.txt","status":"M","folder":false}]}`
2169
+
2170
+ `marks` contains each asked absolute `path`, its `status`, and whether it is a `folder` with
2171
+ changes inside. A clean row has `status:"",folder:false`. Non-empty statuses are exactly
2172
+ `M` modified, `A` added, `D` deleted and `?` untracked. A deleted flag in an ordinary or
2173
+ rename/copy record takes precedence, so an `RD` destination uses `D`; other rename/copy
2174
+ destinations use `A`, and other tracked changes use `M`.
2175
+ An unmerged record uses its worktree mode: a present conflicted path uses `M`, including `UD` and
2176
+ `DU` modify/delete conflicts, while a zero worktree mode uses `D`. No per-file stat is needed.
2177
+ A coalesced index-deletion/untracked pair instead emits one `?`, as described under `gitstatus`.
2178
+ A folder square takes precedence over a simultaneous
2179
+ letter for a submodule or embedded repository. The window square is 6 px, yellow and centred
2180
+ in the 14 px mark column. Resting letters use yellow, green, error and muted roles respectively,
2181
+ each at least 4.5:1; cursor-row text uses foreground. `folder` supplies the same yellow `■`
2182
+ to the TUI's one-cell column. No marks outside the viewport are requested or painted.
2183
+
2184
+ ### gitchanges
2185
+
2186
+ `{"t":"gitchanges","id":3,"path":"/repo","ok":true,"root":"/repo","label":"main · 8 changed","total":8,"start":0,"entries":[{"path":"Code/lib.rs","status":"D","original":"","deleted":true}]}`
2187
+
2188
+ `root` and `label` identify the same cached snapshot as `gitstatus`; `total` is its complete
2189
+ unique changed-path count after coalescing, `start` the clamped page offset, and `entries` the requested page in
2190
+ repository-relative path order. Each entry supplies `path`, `status`, `original` and `deleted`.
2191
+ `status` has the same four-letter alphabet as `gitrows`. `original` is the source path for
2192
+ a rename or copy and otherwise empty; it is metadata, not another counted entry. `deleted`
2193
+ is true for a `D` row even when that path no longer exists. These replies contain no size or
2194
+ date; clients resolve metadata off-thread for existing visible paths and skip it for deleted rows.
2195
+ The Changes footer counts `total`, for example `8 items`, separately from ordinary listing rows.
2196
+
2197
+ All three Git requests fail with their own `t`, echoed `id` and `path`, `ok:false` and an
2198
+ `error` sentence, for example `{"t":"gitstatus","id":1,"path":"/repo","ok":false,"error":"Git status timed out."}`.
2199
+ Errors include unavailable snapshots or sandbox, invalid paths or visible-row requests,
2200
+ cancellation, timeout, over-limit output, malformed porcelain and underlying I/O or Git diagnostics.
2201
+ These are requested replies; `changed` remains the only unsolicited notification.
2202
+
2203
+ ### listed
2204
+
2205
+ `{"t":"listed","n":<uint>,"read":<float>,"sort":<float>,"v":<uint>,"w":<bool>,"path":<string>}`
2206
+
2207
+ Example: `{"t":"listed","n":100000,"read":26.400,"sort":2.500,"v":56,"w":true,"path":"/home/gm"}`
2208
+
2209
+ `n` is the row count. `read` and `sort` are milliseconds, formatted to three decimal
2210
+ places (`{:.3}`). Sent after a successful `list` and after a successful `sort`. `read` is
2211
+ the scan plus any requested metadata pass after `list`, the metadata pass after a
2212
+ `sort` by `size` or `mtime` (including its `date` alias), and `0.0` after a `sort` by
2213
+ `name` or `kind`, which needs no metadata pass.
2214
+
2215
+ `v` is the listing directory's own filesystem id, sent once here rather than on every row because
2216
+ every file in the directory shares it. A client compares it against the `v` of the directory row
2217
+ being dropped on: equal is one volume and the drag moves, different is two and it copies. `v` is 0
2218
+ when the directory could not be stat'd, and a client reads 0 as unknown and copies, because copying
2219
+ where a move was meant is an annoyance and moving where a copy was meant loses the original.
2220
+ `w` is whether this user can create or delete entries in that directory. A drag from a directory
2221
+ with `w` false copies, because a move would have to delete the originals and cannot. A reply that
2222
+ omits `w` is an older backend, and a client reads that the same as true.
2223
+
2224
+ `path` is the directory exactly as the `list` that made this listing spelled it, byte for byte, with a
2225
+ trailing slash, a symlink or a `..` left unresolved, because a client drops any `listed` line whose
2226
+ `path` differs from the one it asked for (`ui/js/Swap.js` `onListed`).
2227
+
2228
+ `changed` rides only on a `list` with `wantChanged` that re-read the directory
2229
+ already listed, and on a `listpaths` that re-read the listing it already held, and names
2230
+ added plus removed rows between the two scans, so a rename counts 2 against a net delta of 0.
2231
+ A first listing and a navigation carry no `changed`, and a client reads a missing one as unknown rather than 0.
2232
+
2233
+ ### rows
2234
+
2235
+ Each viewport row also carries `b`, an optional creation timestamp as Unix seconds
2236
+ or null. The source is Linux `statx(STATX_BTIME)`, with no-follow and no-automount
2237
+ flags and the returned mask checked. Missing support stays null. It is read only
2238
+ in the existing bounded viewport metadata worker, except an explicit creation-time
2239
+ sort. Serialization performs no extra filesystem read. Cached GIO rows without
2240
+ a creation-time fact report null. TUI Creation
2241
+ detail displays a localized date or the localized unavailable value. Properties'
2242
+ single-item `menuaction` payload carries the same optional value as `birth`.
2243
+
2244
+
2245
+ `{"t":"rows","start":<uint>,"rows":[{"n":<string>,"d":<bool>,"s":<uint>,"m":<int>,"p":<uint>,"i":<string>,"t":<bool>,"k":<uint>[,"l":<string>][,"v":<uint>][,"tg":[<string>,...]][,"a":true][,"x":false]},...],"kinds":[<string>,...],"ms":<float>,"listing":<uint>}`
2246
+
2247
+ Example:
2248
+ `{"t":"rows","start":0,"rows":[{"n":"say \"hi\".txt","d":false,"s":12,"m":1787790423,"p":33188,"i":"text-x-generic","t":false,"k":0},{"n":"photos","d":true,"s":4096,"m":1787790424,"p":16877,"i":"folder","t":false,"k":1,"v":56}],"kinds":["Plain text document","Folder"],"ms":1.250,"listing":1}`
2249
+
2250
+ `start` echoes the requested start, clamped to the listing's length: a `window`
2251
+ whose `start` lands past the end of the listing answers with `start` equal to the
2252
+ listing's length and an empty `rows` array, not the value that was requested.
2253
+
2254
+ Each row object holds a name (`n`, JSON-escaped; a valid UTF-8 name may carry any
2255
+ character including a quote or a newline, but a non-UTF8 name arrives lossy and then
2256
+ cannot be stat'd, so it reports zeroes, see AGENTS.md "Deliberate corners"), a
2257
+ directory flag (`d`, the link's own type rather than its target's: a symlink to a
2258
+ directory reports `d:false` and carries `S_IFLNK` in `p`), a size in bytes (`s`), an
2259
+ mtime as a unix timestamp (`m`), a raw `st_mode` (`p`), a freedesktop icon name
2260
+ (`i`), a thumbnailable flag (`t`), and a Kind index (`k`, into the envelope's own
2261
+ `kinds` array).
2262
+
2263
+ **`x:false` means the reported execute bits do not establish per-file executable status.** Only those
2264
+ rows carry it; ordinary rows gain no wire bytes. The icon and executable name colour use
2265
+ the same backend decision, while `p` stays the reported mode for permission display and
2266
+ operations. A symlink's name still uses symlink colour, and a lifted name uses foreground.
2267
+
2268
+ **`l` is where a symlink points, and only a symlink row carries it.** It is the link's own bytes,
2269
+ verbatim and unresolved, so a relative target stays relative and a broken link still names where it
2270
+ points; a `readlink` that fails leaves the field off entirely, the same as any other row. The row it
2271
+ sits on is the one that already pays a second stat for its icon, so the extra call lands on the rows
2272
+ that were already the exception and on no others, and the scale fixture holds no links at all. A row
2273
+ never carries both `l` and `v`: `d` is the link's own type, so a symlink's `d` is `false` and it is
2274
+ never the directory row `v` is for. The client draws the target beside the name and the word `link`
2275
+ in the size column, because a link's own `st_size` is the length of that target path and not a size
2276
+ anyone means.
2277
+
2278
+ **`tg` names the file tags Dolphin reads, and only a tagged row carries it.** It is the
2279
+ `user.xdg.tags` value filtered to names Flea knows, in the stored order and canonical case, so a
2280
+ row draws its swatches with no call per row; an untagged row carries no `tg` at all, and a
2281
+ foreign word stays untouched and shows in Properties instead. One `getxattr` per emitted row
2282
+ reads it, which is exactly the rows a window stats and never a sweep; on a network or phone
2283
+ mount no row carries it, because each read there would be a round trip.
2284
+
2285
+ **`a` marks a thumbnailable AppImage, and only that row carries it.** Its own icon is its identity, so
2286
+ the Images thumbnail mode draws it while a row with any other executable icon, such as a Windows
2287
+ installer a third-party thumbnailer handles, stays a mark; every other row carries no `a` at all.
2288
+
2289
+ **`v` is the row's filesystem id, and only a directory row carries it.** A drop destination is
2290
+ always a directory, so a file row's device would never be read, and the scale fixture is 100,000
2291
+ files with no directories at all: sending it per row would be paid 100,000 times on the one path
2292
+ that produces the headline listing number, for a field nothing reads. A client compares the `v` of
2293
+ the rows being dragged against the `v` of the directory being dropped on: equal means one volume and
2294
+ the drag moves, different means two and it copies, which is what Finder does. A directory whose stat
2295
+ failed reports `p` 0 and `v` 0 with it. `ms` is the phase-2 stat time for this window, formatted to three
2296
+ decimal places. Sent after `list` and after `window`. The last field is `listing`, the numbering these
2297
+ rows are in; see "listing".
2298
+
2299
+ A row whose stat failed sends `s`, `m` and `p` all 0, and `p` is what says so: 0 is outside
2300
+ `st_mode`'s domain, because a real one always carries its file-type bits. So `p` 0 needs no flag
2301
+ beside it the way `lines` 0 needs `lfailed`, and it condemns that row's `s` and `m` with it: those
2302
+ two are only readable when `p` is non-zero.
2303
+
2304
+ **`kinds` is a dictionary, not a string per row, because an earlier plan measured the
2305
+ per-row-string shape and it cost milliseconds.** A directory has few distinct Kind
2306
+ strings even at 100,000 files (a handful of file types, plus `"Folder"`), so every row
2307
+ paying one integer instead of a repeated string keeps the response small without a
2308
+ second round trip. A directory's Kind is always the literal `"Folder"`; a file's Kind
2309
+ is `src/backend/kind.rs`'s freedesktop `<comment>` for its resolved MIME type,
2310
+ canonicalised through `/usr/share/mime/aliases` the same way the thumbnailer spec
2311
+ lookup already is, falling back to the literal `"Data"` both for a type with no comment
2312
+ at all (`application/x-ms-dos-executable` is the one shipped example) and for a name no
2313
+ glob matched. **An icon name is an internal string and this column is a human
2314
+ description, so one never leaks into the other**, and a name nothing identified must not
2315
+ claim to be text however far the icon ladder itself falls. Like the icon and
2316
+ MIME tables, the comment file is read once per distinct type for the life of the
2317
+ backend process, never per row. The selected description is cached per type and
2318
+ refreshed when Omarchy's language changes: the client is given a human description and never the
2319
+ MIME type, because a display should not classify.
2320
+
2321
+ **English uses the first `<comment>` carrying no `xml:lang` attribute, preserving its
2322
+ bytes. Other languages select an exact language and region, then the base language,
2323
+ then that untranslated comment.** Underscores and hyphens name the same locale;
2324
+ the roster's Chinese regions and Cyrillic Serbian also match their explicit XML
2325
+ script tags. Translated XML entities are decoded before display. 8 of the first 200
2326
+ `/usr/share/mime/*.xml` files on this box open with a translated comment before their
2327
+ untranslated one; `application/msword.xml` is one of them, opening `xml:lang="zh-Hant-TW"`
2328
+ with its untranslated comment further down the file, after every alias and glob line. A type
2329
+ whose file carries no matching translated or untranslated comment,
2330
+ answers `None`, which is why `Kinds::comment` returns `Option<String>` rather than an empty
2331
+ string.
2332
+
2333
+ An explicit `mime.markdown` catalog value overrides the localized Markdown comment
2334
+ outside English. Only Japanese supplies one: `Markdown 文書`, as HIG
2335
+ `CHANGES.md:2800` requires, correcting the installed incomplete `Markdown ` comment.
2336
+ Every other locale continues through the MIME selection and fallback above.
2337
+
2338
+ **No wording in that file is a property of the format, and nothing may assert one.** Every
2339
+ `/usr/share/mime/<type>.xml` is generated by `update-mime-database` from the files in
2340
+ `/usr/share/mime/packages/`, and no package owns the generated file, so the untranslated
2341
+ string is whatever the applications installed on the box supply. `application/msword` reads
2342
+ `Word document` from `shared-mime-info`'s own `freedesktop.org.xml` and `Microsoft Word
2343
+ Document` from `libreoffice-fresh`'s `libreoffice.xml`, which outranks it; a box with
2344
+ LibreOffice and a box without therefore disagree on the same distribution and the same
2345
+ `shared-mime-info` version. Issue 1 was a test asserting the second string. The parser is
2346
+ tested against a fixture and the live database is asserted only for shape: reachable, ASCII,
2347
+ and free of markup.
2348
+
2349
+ Note that `t` inside a row object is a boolean, while the `t` at the top of every
2350
+ response line is the message type, a string. They never collide: one is a member of a
2351
+ row, the other a member of the envelope.
2352
+
2353
+ `i` is never empty. The backend resolves the row's name to a MIME type against
2354
+ `/usr/share/mime/globs2`, then that type to an icon name against
2355
+ `/usr/share/mime/generic-icons`, falling back to the media class (`image-x-generic`
2356
+ and friends) and finally to `text-x-generic`; a directory is always `folder`. Both
2357
+ tables are read once per backend process, never per row. The client is given the icon
2358
+ name and never the MIME type, because a display should not classify.
2359
+
2360
+ Two rules sit above that resolution and neither follows from the name alone. **The icon of a
2361
+ symlink follows the link's target while `d` still describes the link**: a symlink to a
2362
+ directory reports `d:false`, because `d` is what the client navigates on, and `i` is `folder`,
2363
+ because that is what the row looks like. Only symlink rows pay for it, through one extra stat
2364
+ inside the window the client asked for. **An `application/*` type with no `generic-icons` entry
2365
+ resolves by the row's own execute bit**, not by its class: 190 of the 613 such types in
2366
+ `globs2` have no entry on this box, so the execute bits in `p` decide between
2367
+ `application-x-executable` and `application-x-generic`. Both counts move with the installed
2368
+ applications, the same merge described above: a root built from `freedesktop.org.xml` alone
2369
+ gives 138 of 552. Without that rule every one of the 190 drew as a program, which is
2370
+ how a `.pem` came to look executable.
2371
+
2372
+ `t` is true only when a validated thumbnailer declares the row's MIME type or an alias
2373
+ of it. The backend resolves the row's name to a MIME type the same way it resolves the
2374
+ icon, then looks that type up in the thumbnailer specs read from the freedesktop search
2375
+ path, whose declarations and queries are both canonicalised through
2376
+ `/usr/share/mime/aliases`, so either side of an alias pair matches. A spec whose
2377
+ program is not a runnable executable is dropped when the table is built, so `t` is
2378
+ false for a declared type with no working thumbnailer. `t` also reads the file type out of the
2379
+ row's own `p`: a directory, a fifo, a socket and a device node are always false however they are
2380
+ named, and a row that vanished between the listing and the stat reports `p` of 0 and is false
2381
+ too. A symlink is true when its name resolves to a declared type, because the thumbnail request
2382
+ stats the target; a symlink to a special file is then refused when the row is actually asked
2383
+ for. Like
2384
+ the icon tables, the alias and spec tables are read once per backend process, never per
2385
+ row; the whole per-row cost of `t` is one hash lookup, tens of nanoseconds per row, so
2386
+ it is invisible at a viewport of a few hundred rows. The client is still never given the
2387
+ MIME type itself: `t` exists so a display does not have to reason about content types to
2388
+ know whether asking for a thumbnail is worthwhile.
2389
+
2390
+ ### searching
2391
+
2392
+ `{"t":"searching","n":<uint>,"scanned":<uint>,"ms":<float>}`
2393
+
2394
+ Example: `{"t":"searching","n":812,"scanned":41200,"ms":300.114}`
2395
+
2396
+ The streaming progress of a `search`, at most one every 100 ms while the walk runs. `n` is
2397
+ the match count so far and is what the client sets its row total to; `scanned` is directory
2398
+ entries looked at, which is the number the status bar counts up. It is deliberately not a
2399
+ `listed` line: a mid-walk update is not a fresh listing and carries no `read` or `sort`
2400
+ timing to report.
2401
+
2402
+ Matches are only ever appended, so a `window` the client already holds stays valid across
2403
+ every one of these.
2404
+
2405
+ ### searched
2406
+
2407
+ `{"t":"searched","n":<uint>,"scanned":<uint>,"ms":<float>,"cancelled":<bool>}`
2408
+
2409
+ Example: `{"t":"searched","n":6252,"scanned":45807,"ms":48.711,"cancelled":true}`
2410
+
2411
+ The terminal line of a `search`. `n` is the final match count and is authoritative: no
2412
+ `listed` follows it. `scanned` is how many directory entries the walk looked at, which is
2413
+ what the status bar counts up while it runs. `ms` is the whole walk, to three decimal
2414
+ places. `cancelled` is true when a `searchcancel`, a `list` or a `sort` ended the walk early,
2415
+ and false when it ran the subtree out. The rows are in rank order by the time this line is
2416
+ written, so a client holding a window from before it has stale indices and re-reads.
2417
+
2418
+ ### thumbed
2419
+
2420
+ `{"t":"thumbed","row":<uint>,"file":"<string>","ms":<float>}`
2421
+
2422
+ Example: `{"t":"thumbed","row":2,"file":"/home/gm/.cache/thumbnails/large/b98fa4082faa4a9cbb9728c94831a4b5.png","ms":75.823}`
2423
+
2424
+ Answers one row of one `thumb` request. `row` is the index that was asked for. `file` is
2425
+ the absolute path of the PNG in the shared thumbnail cache, JSON-escaped like every other
2426
+ string on this wire, and `ms` is milliseconds to three decimal places: the whole job for a
2427
+ generated row, or just the lookup for a row answered from the cache, which is tens of
2428
+ microseconds here against tens of milliseconds and up for a decode.
2429
+
2430
+ **`file` is empty rather than absent on failure**, so a client never waits forever for a
2431
+ row that will not arrive. Every reason a row cannot be thumbnailed reads the same on the
2432
+ wire: a directory, a file that is not a regular file, no thumbnailer for the type, a recorded
2433
+ failure, a thumbnailer that failed, hung, or exited successfully having written nothing, or a
2434
+ shutdown that cut the job short. A client that asked for a row
2435
+ therefore gets exactly one `thumbed` line for it, unless it cancelled the row itself or
2436
+ replaced the listing under it.
2437
+
2438
+ **A result for a superseded listing is dropped, never reported against the current one.**
2439
+ The backend maps only the rows a client actually asked for back to their paths, and a
2440
+ `list` or a `sort` clears that map and cancels the queue, because both change which row an
2441
+ index names. A job already inside a worker still runs to its own end, and its result is
2442
+ discarded on arrival rather than printed against a row it no longer describes.
2443
+
2444
+ Shutdown is bounded at 25 seconds in total, which is longer than the pool's own 20-second
2445
+ job deadline, so no single running job can be cut short by it. If the budget does run out,
2446
+ every row still unanswered is answered with an empty `file` before the process exits.
2447
+
2448
+ ### dirsized
2449
+
2450
+ `{"t":"dirsized","row":<uint>,"bytes":<uint>,"partial":<bool>,"ms":<float>}`
2451
+
2452
+ Example: `{"t":"dirsized","row":4,"bytes":1048576,"partial":false,"ms":12.500}`
2453
+
2454
+ Answers one row of one `dirsize` request. `row` is the index that was asked for, `bytes` is the
2455
+ recursive apparent size (every entry's own `st_size`, symlinks not followed and counted at their
2456
+ own small size rather than their target's), and `ms` is the walk's own wall-clock time. The
2457
+ target's own directory entry counts too, matching what `du -s` reports for the directory itself.
2458
+
2459
+ **`partial` is true when the 2000&nbsp;ms deadline cut the walk short, or a subtree inside it
2460
+ answered permission denied.** Either way `bytes` is a floor, honestly labelled, never a wrong
2461
+ exact number: everything the walk actually saw before it had to stop is still counted. The
2462
+ shipped client renders a partial answer with a leading `>`.
2463
+
2464
+ **A result for a superseded listing is dropped, never reported against the current one.** A
2465
+ `list` or a `sort` clears the answered-row cache and cancels the queue, the same rule and the
2466
+ same reason `thumbed` follows: both change which row an index names.
2467
+
2468
+ ### settagged
2469
+
2470
+ `{"t":"settagged","ok":<uint>,"op":"<string>"[, "skipped":["<name>",...]]}`
2471
+
2472
+ Example: `{"t":"settagged","ok":2,"op":"remove"}`
2473
+
2474
+ Answers one `settags` request. `ok` is the number of files whose attribute changed; a request
2475
+ that changed nothing answers 0 and records nothing to undo. `op` is what was applied, `add`,
2476
+ `remove` or `clear`, so a `toggle` answers the direction it resolved to, and the client words its
2477
+ status line from it. `skipped` is present only when a row was skipped, naming the rows that were neither a file nor a folder, so the client words that line instead. A refusal answers an `error`
2478
+ line with `where` of `settags` instead, and nothing was written.
2479
+
2480
+ ### tagstates
2481
+
2482
+ `{"t":"tagstates","id":<uint>,"applied":["<name>",...],"mixed":["<name>",...],"listing":<uint>}`
2483
+
2484
+ Example: `{"t":"tagstates","id":3,"applied":["Red"],"mixed":["Blue"],"listing":7}`
2485
+
2486
+ The answer to one `tagstates` request, carrying its `id` back. `applied` names, in menu order, the
2487
+ tags every row carries and `mixed` the tags only some rows carry; a tag no row carries is in neither.
2488
+ A name Flea does not know is never listed. `listing` is the numbering the rows resolved in, so a
2489
+ client drops an answer whose numbering is not the one it asked in.
2490
+
2491
+ ### transferstarted
2492
+
2493
+ `{"t":"transferstarted","id":<uint>,"n":<uint>,"moving":<bool>}`
2494
+
2495
+ Example: `{"t":"transferstarted","id":12,"n":2,"moving":true}`
2496
+
2497
+ `id` names this operation for the life of the process and is what `transfercancel` takes. `n` is the
2498
+ count of top-level paths, **never a recursive file count**: computing that before answering would be a
2499
+ metadata sweep over an unbounded subtree, which is the shape this codebase refuses everywhere else, and
2500
+ a transfer's first line is on the critical path of every operation.
2501
+
2502
+ `moving` is the verb the request actually resolved to, after the rule that anything which is not
2503
+ exactly `"move"` copies. It is on the wire because the client cannot derive it: a paste spends a cut
2504
+ clipboard before this line arrives, and a move to Dropbox never touches the clipboard at all, so a
2505
+ client reading its own clipboard reports a move as a copy.
2506
+
2507
+ ### extractstarted
2508
+
2509
+ `{"t":"extractstarted","id":<uint>}`
2510
+
2511
+ An archive `extract` sends this line before its one zero-byte `transferprogress` line. A current client
2512
+ routes it into the existing transfer card as `n` 1, `moving` false and `extract` true, so the card says
2513
+ Extracting without inventing bytes, a rate or an ETA. Older clients ignore this new type, while the
2514
+ existing `archivedone` terminal line remains unchanged for archive clients.
2515
+
2516
+ ### meta
2517
+
2518
+ `{"t":"meta","row":<uint>,"w":<uint>,"h":<uint>,"orient":<uint>,"ms":<uint>,"rate":<uint>,"entries":<uint>,"unpacked":<uint>,"afailed":<bool>,"names":[{"n":"<string>","d":<bool>},...],"lines":<uint>,"partial":<bool>,"lfailed":<bool>,"frames":<uint>,"photo":<object|null>,"target":"<string>","targetdir":<bool>,"raw":{"file":"<string>","w":<uint>,"h":<uint>,"orient":<uint>}|null,"owner":"<string>"}`
2519
+
2520
+ Example: `{"t":"meta","row":4,"w":0,"h":0,"orient":1,"ms":0,"rate":0,"entries":214,"unpacked":3400,"afailed":false,"names":[{"n":"ui","d":true}],"lines":0,"partial":false,"lfailed":false,"frames":0,"photo":null,"target":"","targetdir":false,"raw":null,"owner":"gm"}`
2521
+
2522
+ Every field a row did not ask for is zero, false or empty. `w` and `h` are pixels, read from the
2523
+ file's own header, as stored and before any turn. `ms` and `rate` come from the probe. `lines` is a
2524
+ newline count with `partial` true when it stopped at its 1 MiB budget, so the column states it as a floor.
2525
+
2526
+ `photo` is the camera file's EXIF facts as stored, from the same RAW walk and in the same reply: `{"make":"Canon","model":"Canon EOS R6","lens":"RF35mm F1.8 MACRO IS STM","exp":[1,250],"fnum":[28,10],"iso":400,"focal":[35,1],"taken":"2026:09:12 18:31:00","w":6000,"h":4000}`, where `exp`, `fnum` and `focal` are numerator and denominator pairs, `w` and `h` are the sensor size from the EXIF pixel dimensions (sent together or not at all) and an absent fact is left out. It is `null` for every row that is not a RAW and for a RAW whose walk names none of them; a JPEG's facts are never in this reply. The `properties` reply of `menuaction` carries the same `photo` object, and for a `.jpg` or `.jpeg` it is read from the first `Exif` APP1 within the file's first 64 KiB.
2527
+
2528
+ `orient` is the EXIF orientation, 1 to 8, from the same read of the same open. The walk that finds a
2529
+ JPEG's frame header reads the head of its first `Exif` APP1 on the way past. It is 1 for every file that
2530
+ names none, every non-JPEG included. Orientations 5 to 8 swap the sides, and the Columns frame and Quick
2531
+ Look ask Qt for their decode size turned the same way, because Qt fits its decode before it turns.
2532
+
2533
+ `entries` and `unpacked` are **exact totals whenever they are non-zero**, because the index is
2534
+ streamed and counting all of it costs no memory. `names` is capped at the first `ARCHIVE_NAME_CAP`
2535
+ entries, which is the only part that is bounded: the tile lists those and states the difference as
2536
+ its own "+ N more" line.
2537
+
2538
+ `lfailed` is true when the row produced no count at all, which on this box
2539
+ means permission denied, a row that is not a regular file, or a row that vanished between the
2540
+ listing and the request. **Nothing but a regular file is ever read here or for `w` and `h`**: a
2541
+ `stat` refuses every other kind before any open, so a FIFO that `stat` sees is never opened at all
2542
+ and its waiting writer is left where it was, and the open behind that stat is `O_NONBLOCK` with a
2543
+ second `fstat` on the descriptor, so a row swapped for a FIFO inside that window is closed again
2544
+ instead of leaving the row waiting forever. That one open does wake a writer parked in `open(2)`, and
2545
+ closing it unread hands that writer an `EPIPE` on its next write if nothing else is reading: it is
2546
+ the cost of refusing a swap the `stat` cannot see, not a case the `stat` avoids. A FIFO, a socket, a
2547
+ device or a directory answers its `stat` facts with no dimensions and no count. It is what tells
2548
+ `lines` 0 apart from an empty file, whose `lines` is also 0: zero is a real count, so
2549
+ unlike `mode` on an `error` line it cannot carry the failure itself. A row that never asked for a
2550
+ count sends `lines` 0 and `lfailed` false, the same as a row whose count really is zero, because
2551
+ nothing was attempted; the client knows which kind it asked about.
2552
+
2553
+ `afailed` is true when the listing was not completed, which covers a tool that could not read the
2554
+ archive and a read that outran its wall-clock budget. **A failed read sends `entries` 0, `unpacked` 0
2555
+ and an empty `names`**, so a partial count never reaches a client that has been told the number is a
2556
+ total. That is a different answer from an archive holding nothing, which sends the same zeroes with
2557
+ `afailed` false, and the two are drawn as the error state and the empty state respectively.
2558
+
2559
+ `target` is a symlink's own target and `targetdir` says whether that target is a directory, so the
2560
+ column's mark can follow the target the way a listing row's does.
2561
+
2562
+ `raw` is an object for a camera RAW whose largest embedded JPEG preview was copied out, and `null` for every
2563
+ other file and for a RAW holding none. `file` is the copy under `$XDG_CACHE_HOME/flea/raw/`, named by the md5
2564
+ of the RAW's file URI and carrying the RAW's mtime, so a changed RAW is copied again and an unchanged one is
2565
+ neither walked nor written twice.
2566
+ The directory is held to 4096 copies and 512 MiB, least recently used first (by ctime, since a copy's mtime is its RAW's:
2567
+ a cache hit sets the copy's atime to now, which moves the ctime), and sweeps temps a minute old. A copy made or served less
2568
+ than a minute ago is never evicted, because the thread that has it is still reading it. Each process scans on its own first
2569
+ new copy and then on one in 32 of its own, and the directory is shared, so it can stand over its caps: by the copies of the
2570
+ last minute, which stay until the first scan after they age, and by fewer than 32 more per process. `w`, `h` and `orient` read like
2571
+ the top-level fields, taken from the copy: stored
2572
+ pixels and the EXIF turn, which the copy carries even when the camera put it only in the RAW's IFD0. The top-level
2573
+ `w` and `h` stay 0 for a RAW, because they name the file's own pixels and the preview is smaller.
2574
+
2575
+ `owner` is the login name of the user who owns the path itself (a symlink reports the link's owner, as
2576
+ its row does), resolved from `/etc/passwd` alone and never through `getpwuid`: that call goes through
2577
+ NSS and can wait on a network directory, and the meta thread must never hang the column on one. A uid
2578
+ no local account carries answers the empty string, never the number dressed as a name.
2579
+
2580
+ ### owner
2581
+
2582
+ `{"t":"owner","row":<uint>,"token":<uint>,"owner":"<string>","retry":<bool>}`
2583
+
2584
+ Example: `{"t":"owner","row":7,"token":91,"owner":"gm","retry":false}`
2585
+
2586
+ The answer to one `{"c":"owner","row":<uint>,"token":<uint>}` request, which the TUI's Owner detail
2587
+ column sends for one drawn row at a time, echoing its `row` and `token`. `owner` reads as `meta.owner`
2588
+ does, the login name from `/etc/passwd`, and is empty for a uid no local account carries, a path that
2589
+ cannot be stat'ed or a row past the listing. `retry` is true only when the stat could not run, because
2590
+ its remote mount already holds an owner stat, eight remote owner stats are held across all mounts, the
2591
+ mount is marked stuck, or the stat missed its five-second deadline. `owner` is empty then too, so a
2592
+ reader that ignores `retry` reads the line it always did, and an absent `retry` reads as false. The
2593
+ TUI keeps every `retry:false` answer, empty ones included, until its rows change, and asks a
2594
+ `retry:true` row again only while the Owner column still draws it, at most once a second.
2595
+
2596
+ ### shebang
2597
+
2598
+ `{"t":"shebang","path":"<string>","hasShebang":<bool>,"id":<uint>}`
2599
+
2600
+ Example: `{"t":"shebang","path":"/home/gm/run.sh","hasShebang":true,"id":3}`
2601
+
2602
+ The answer to one `shebang` request, carrying its `id` back. `hasShebang` is true only when the
2603
+ path opened as a regular file and its first two bytes were `#!`; anything else, a directory, a
2604
+ FIFO, a missing path or an unreadable file, answers false. The open is the backend's own
2605
+ regular-file open, so a FIFO is refused before any open and a row swapped for one inside that
2606
+ window is closed again, the same rule `meta` follows.
2607
+
2608
+ ### code
2609
+
2610
+ `{"t":"code","token":<uint>,"slot":"<string>","state":"<string>","first":<uint>,"total":<uint>,"lines":[[<role>,"<string>",...],...][,"records":<uint>,"firstRecord":<uint>]}`
2611
+
2612
+ Example: `{"t":"code","token":4,"slot":"look","state":"styled","first":1,"total":2,"lines":[[5,"fn",0," main"],[]]}`
2613
+
2614
+ The answer to one `code` request, echoing its `token` and `slot`. `first` is the actual first
2615
+ line returned: normally the asked line, or the record start for a raw `csv` table request.
2616
+ `state` is `styled`
2617
+ (`bat` coloured the lines), `plain` (the lines are the file's own text, every role 0: a line over
2618
+ 5,000 bytes, or no `bat` on the host), `binary` (a NUL in the first 1,024 bytes), `large` (over
2619
+ 1,048,576 bytes) or `failed` (unreadable, or `bat` refused it); the last three carry no lines.
2620
+ `total` is the line count of the whole text. Each entry of `lines` is one line as a flat run list,
2621
+ a role from 0 to 6 then the run's text, in order, an empty line being an empty list; the lines
2622
+ returned are those asked for that exist, extended to whole records for a raw `csv` table
2623
+ request. `total` remains the physical-line count in either case. A plain raw `csv` reply
2624
+ also carries `records`, the whole file's record count including its header, and `firstRecord`,
2625
+ the ordinal of the first returned record. An unterminated quote at EOF still counts as one
2626
+ record. An empty file has zero records; an empty reply beyond EOF has the requested record
2627
+ ordinal, or the next ordinal after the file for a physical-line ask. Every other reader's
2628
+ reply omits these two fields. The roles are the foreground (0) and bat's ANSI colours
2629
+ 31 to 36 in order: red, green, yellow, blue, magenta, cyan. A one-line `.json` file is formatted
2630
+ with `jq` first, so its `total` and lines are those of the formatted text.
2631
+
2632
+ ### html
2633
+
2634
+ `{"t":"html","token":<uint>,"slot":"<string>","state":"<string>","file":"<PNG path>","total":<uint>,"width":<uint>,"title":"<string>"}`
2635
+
2636
+ Example: `{"t":"html","token":3,"slot":"look","state":"ready","file":"/cache/thumbnails/flea-html/a.png","total":61,"width":984,"title":"Local page"}`.
2637
+
2638
+ The answer to the latest request in one HTML slot. State is `ready`, `failed`, or `cancelled`;
2639
+ only `ready` names a PNG and a whole-file line count. Failure uses the existing unreadable-file
2640
+ frame. Width is the render width, not a CSS zoom. Title is the helper's observed static page
2641
+ title on a new render and empty on a cache hit. The client matches token and slot, and drops
2642
+ replies after closing or switching to Source.
2643
+
2644
+ ### compare
2645
+
2646
+ `{"t":"compare","token":<uint>,"state":"<string>","patch":"<string>"}`
2647
+
2648
+ Example: `{"t":"compare","token":3,"state":"diff","patch":"--- /a\n+++ /b\n@@ -1 +1 @@\n-x\n+y\n"}`
2649
+
2650
+ The answer to one `compare` request, echoing its `token`. `state` is `diff` (`diff` exited 1 and `patch`
2651
+ holds what it printed, headers included), `identical` (it exited 0), `binary` (screening found a NUL in
2652
+ a file's first 8,192 bytes), `over` (either file is over 1,048,576 bytes) or `failed` (a path
2653
+ that cannot be read as a regular file, invalid UTF-8 in a file or the patch, any other exit, `diff` or
2654
+ the jail not starting, or a patch past 8 MiB). `patch` is the empty string in every state but `diff`.
2655
+
2656
+ Screening first requires both absolute paths to open as regular files with readable lengths;
2657
+ either initial length over 1,048,576 bytes returns `over`. It then reads `a`, followed by `b`, up to
2658
+ 1,048,577 bytes each, checking read failure (`failed`), growth past the limit (`over`), a NUL in the
2659
+ first 8,192 bytes (`binary`), then UTF-8 validity (`failed`), stopping at the first refusal. An earlier
2660
+ file's `failed` or `over` therefore takes precedence over a later file's NUL.
2661
+
2662
+ ### collisions
2663
+
2664
+ `{"t":"collisions","id":<uint>,"total":<uint>,"names":[{"n":"<string>","d":<bool>,"i":"<string>"},...],"mergeable":<bool>}`
2665
+
2666
+ Example: `{"t":"collisions","id":7,"total":2,"names":[{"n":"screenshot.png","d":false,"i":"image-x-generic"},{"n":"notes","d":true,"i":"folder"}],"mergeable":true}`
2667
+
2668
+ `total` counts every colliding source. `names` is the first three of them in request order, which is
2669
+ all a client's card lists before its "and N more" line, so the answer stays small however wide the
2670
+ selection. `n` is the incoming item's name, `d` whether it is a directory and `i` the same icon name a
2671
+ `rows` row carries, for the card's kind mark. `mergeable` is true when a folder lands on a folder,
2672
+ which is the only shape the card offers `merge` for. A `total` of 0 means nothing collides; the shipped client
2673
+ then sends its transfer with `collide` `refuse`, so a name that appears in the meantime is still refused.
2674
+
2675
+ ### fatnames
2676
+
2677
+ `{"t":"fatnames","id":<uint>,"fstype":"<string>","volume":"<string>","renames":[{"old":"<string>","new":"<string>","d":<bool>,"i":"<string>"},...]}`
2678
+
2679
+ Example: `{"t":"fatnames","id":7,"fstype":"exfat","volume":"128GB","renames":[{"old":"clip: final.mp4","new":"clip_ final.mp4","d":false,"i":"video-x-generic"}]}`
2680
+
2681
+ Answers a `collisions` question that carried no `fat` for a FAT-family destination, with the same
2682
+ `id`, in place of the `collisions` line. `fstype` is the mount table's own word and `volume` the
2683
+ owning mount's folder name, so the card names the drive from fields and never from a sentence.
2684
+ `renames` lists every source whose name the filesystem refuses, in request order, `old` as it is and
2685
+ `new` as Rename and copy would land it; `d` and `i` are the `rows` row's directory bit and icon name.
2686
+
2687
+ ### mergecollisions
2688
+
2689
+ `{"t":"mergecollisions","id":<uint>,"total":<uint>,"names":[{"n":"<string>","d":<bool>,"i":"<string>"},...]}`
2690
+
2691
+ Example: `{"t":"mergecollisions","id":8,"total":3,"names":[{"n":"IMG_4120.HEIC","d":false,"i":"image-x-generic"}]}`
2692
+
2693
+ `total` counts every file-level name both sides hold; folders that merge further down are not
2694
+ listed because they need no answer. `names` is the first three in walk order with the same `n`,
2695
+ `d` and `i` the card draws, `n` relative to the merging folder when one folder merges and to the
2696
+ destination when several do. A `total` of 0 sends no second card: the transfer goes out at once
2697
+ with `inner` `refuse`.
2698
+
2699
+ ### clip
2700
+
2701
+ `{"t":"clip","op":"set","ok":<bool>,"token":<string>}`
2702
+ `{"t":"clip","op":"get","ok":<bool>,"clip":"copy"|"cut"|"image"|"none","paths":[<string>,...],"token":<string>,"skipped":<uint>}`
2703
+ `{"t":"clip","op":"clear","ok":<bool>,"cleared":<bool>}`
2704
+ `{"t":"clip","op":"changed","clip":"copy"|"cut"|"image"|"none","paths":[<string>,...],"token":<string>,"skipped":<uint>}`
2705
+
2706
+ One shape for all three clipboard requests, read by `clipResult`. A refusal carries
2707
+ `ok:false` and an `error` sentence instead of the success fields. A `get` with no
2708
+ files on the clipboard answers `clip:image` if PNG is offered, otherwise `clip:none`,
2709
+ with empty `paths` and `token` in either case. Only file selections enable Paste As;
2710
+ `image` enables ordinary Paste, which requests the PNG bytes through `pasteimage`.
2711
+ `skipped`
2712
+ counts the offered URIs that were refused (bad escapes, NUL, relative paths, `.` or
2713
+ `..` parts, non-UTF-8, duplicates, other schemes) rather than failing the read. The
2714
+ `token` on a `get` names Flea's own copy when the selection carries it, which is what
2715
+ a later `clipClear` hands back; a foreign selection answers an empty one. `changed`
2716
+ is the watcher's line and carries no `ok`: it reports the selection as files, with the
2717
+ same fields as `get`, and a failed watch reports `none` with an `error` sentence.
2718
+ A selection event supersedes any exit read begun before it, while an exit read begun after that event invalidates its pending watcher bytes only if it reports, with both guards ordered by sequences under the emitter lock.
2719
+
2720
+
2721
+ ### transferprogress
2722
+
2723
+ `{"t":"transferprogress","id":<uint>,"index":<uint>,"name":"<string>","bytes":<uint>,"total":<uint>[,"phase":"writing"]}`
2724
+
2725
+ Example: `{"t":"transferprogress","id":12,"index":0,"name":"a.txt","bytes":40000000,"total":120000000}`
2726
+
2727
+ Throttled to one window per 150 ms for the whole operation, not per item: the first sample goes out
2728
+ at once and a run of small files shares one window, and a window sends the item's own line with at most
2729
+ one owed line beside it (below), so a fast copy of a thousand small files sends a few
2730
+ lines (plus one `transferitem` per item, below), and a single large file sends one per window as it
2731
+ always did. A thousand small files once sent a progress line and an item line each, so the card model
2732
+ was written 2002 times; the throttle cuts the progress lines from 1000 to 2 and the writes to 1004. An item
2733
+ that sends no line of its own is correct and not a missing message.
2734
+
2735
+ **Bytes ride on the next line, never get lost.** A client counts an item's bytes from the last sample it
2736
+ saw for that item (`ui/js/Transfer.js` `itemDone`), so the bytes of items that sent no line go out once, ahead
2737
+ of the next item's first line: a sample with `total` 0 under the `index` and `name` of the last item to end,
2738
+ which the client has already closed, so it joins the running sum and moves nothing on the card. The next item
2739
+ then sends its own `bytes` and `total`, so a large file keeps its size on every line. A closing sample of the
2740
+ same shape follows the last item when anything is still unreported, ahead of the drive flush below. What the
2741
+ card draws is then the figure an unthrottled run draws, to the byte, whatever the
2742
+ throttle skipped; an item that ends short (a failure or a cancel) counts as the total its last line
2743
+ named, as it always did. Only a copy or a same-device move sent by the transfer loop paces this way; a
2744
+ cross-device move batch and a folder merge keep their own per-item windows.
2745
+
2746
+ **Only a regular file reports bytes.** A directory has no total without the sweep this codebase does not
2747
+ do, and a same-filesystem move is a single `rename(2)` with nothing to report partway through, so
2748
+ neither emits these lines at all. A client renders an item with no progress as indeterminate.
2749
+ On a durable target (usb, phone, network, or vfat/exfat/ntfs) those bytes are reported only after
2750
+ the drive confirms them, so the rate is the drive's real rate.
2751
+
2752
+ **The final phase rides one more of these lines.** After the last file, the transfer emits
2753
+ `{"t":"transferprogress","id":<uint>,"index":0,"name":"","bytes":0,"total":0,"scanned":0,"phase":"writing","drive":"<string>"}`
2754
+ while it fsyncs every directory it created or wrote into. Cancel is not honoured there because
2755
+ every file is already complete. `drive` is the destination's own name, so the card's headline
2756
+ names where the flush is going without guessing from timing. Older clients ignore the fields
2757
+ they never asked for.
2758
+
2759
+ **An extract's one progress line is not a transfer's.** It carries the archive's own file name as
2760
+ `name` with `bytes` and `total` both 0: the unpacking tool streams no per-item bytes, so any figure for
2761
+ it would be invented and no percentage or time left is offered for it.
2762
+
2763
+ ### transferitem
2764
+
2765
+ `{"t":"transferitem","id":<uint>,"index":<uint>,"name":"<string>","ok":<bool>,"err":"<string>"}`
2766
+
2767
+ Example: `{"t":"transferitem","id":12,"index":0,"name":"a.txt","ok":true}`
2768
+ Example: `{"t":"transferitem","id":12,"index":1,"name":"photos","ok":false,"err":"permission denied"}`
2769
+
2770
+ Exactly one per top-level item the transfer starts, in the order the items were named; an item it
2771
+ never starts, one a cancel reached first, one a `skip` left or a move already in place, answers none. **`err` rides only on a failure**, so
2772
+ a successful item's line carries no empty field to reason about, and a permission error on one file is
2773
+ that item's data rather than the operation's: the batch carries on to the next item. What a failed item
2774
+ had already written stays where it is and is journaled as that operation's own creation, so an `undo`
2775
+ removes it; only a cancel removes its partial itself, see `transfercancel`.
2776
+
2777
+ ### transferdone
2778
+
2779
+ `{"t":"transferdone","id":<uint>,"ok":<uint>,"failed":<uint>,"skipped":<uint>,"cancelled":<bool>,"retryPaths":[<string>,...],"durable":<bool>,"note":"<string>"[,"merged":<uint>,"keptBoth":<uint>,"into":"<string>"][,"refused":[{"name":"<string>","err":"<string>"}]]}`
2780
+
2781
+ Example: `{"t":"transferdone","id":12,"ok":1,"failed":1,"skipped":0,"cancelled":false,"retryPaths":[],"durable":false,"note":""}`
2782
+ Example: `{"t":"transferdone","id":13,"ok":0,"failed":1,"skipped":0,"cancelled":false,"retryPaths":["/home/a/thesis"],"durable":true,"note":"","refused":[{"name":"thesis/refs/paper.pdf","err":"this drive cannot hold links"}]}`
2783
+
2784
+ The whole operation's terminal line. `skipped` counts the items the transfer did not start by design:
2785
+ those a cancel reached before they started, the colliding items a `collide` of `skip` left in place,
2786
+ and an item a `collide` move would have put back where it already is. A cancelled `replace` whose
2787
+ put-back failed is counted in `failed` instead, see `transfer`. `cancelled` is true when a
2788
+ `transfercancel`, a `quit` or stdin closing ended it early. `durable` is true only when the
2789
+ destination needed durability and every flush succeeded, so the UI can say "written to the drive";
2790
+ local targets report false and never claim it, and so do rclone ones, which skip the flush by design.
2791
+ `note` carries the sentence the UI prints beside the done count: `copied, but the drive did not
2792
+ confirm the folder` when the files landed but a folder flush did not, and `rclone uploads them in
2793
+ the background` for a copy onto an rclone mount, and is empty otherwise. `refused` rides only when an item
2794
+ failed for entries a drive refused inside its tree, see "An entry the destination cannot hold" above: one
2795
+ object per entry, its path under `dest` and its reason, in the order the walk met them and at most 100 of
2796
+ them across the transfer; that item's own `err` carries its full count. An item that ended on any other
2797
+ error adds nothing to `refused`. A merge carries two more
2798
+ counts, `merged` for every entry it landed (the inner entries of folded folders and every other
2799
+ landed item, including ones that waited on a move batch) and `keptBoth` for the landed entries its
2800
+ answer kept both, top-level and inner alike, and `into` naming the one merging folder, or the
2801
+ destination several folders folded into, so the status line needs no client state; older clients
2802
+ ignore the fields they never asked for. A merge's `refused`, when present, follows `into`.
2803
+
2804
+ ### flashstarted
2805
+
2806
+ `{"t":"flashstarted","id":<uint>,"total":<uint>,"known":<bool>}`
2807
+
2808
+ The flash worker's first line, answering what will be written: `total` is the unpacked size
2809
+ when `known` is true and the file's own size otherwise. Every `flashprogress` and the one
2810
+ `flashdone` behind it carry the same `id`.
2811
+
2812
+ ### flashprogress
2813
+
2814
+ `{"t":"flashprogress","id":<uint>,"phase":"prepare|password|write|flush|verify","bytes":<uint>,"total":<uint>,"permille":<uint>}`
2815
+
2816
+ At most one line per 150 ms, the transfer rule, whatever the drive answers. `password` runs
2817
+ from the helper's spawn to its descriptor, so the card says "waiting for your password";
2818
+ `flush` covers the final `fsync`, during which Cancel is dimmed, and `prepare` precedes
2819
+ the password prompt while the drive is re-read. See `flash` for what
2820
+ `bytes` and `permille` count in each phase.
2821
+
2822
+ ### flashdone
2823
+
2824
+ `{"t":"flashdone","id":<uint>,"ok":<bool>,"phase":"<string>","at":<uint>,"err":"<string>","blank":<bool>}`
2825
+
2826
+ The flash worker's one terminal line, which releases the backend's flash slot. `phase`
2827
+ names where it ended: `verify` on success, the failing phase otherwise. `at` is the first
2828
+ differing offset on a `verify` failure and 0 elsewhere. See `flash` for every `err` and
2829
+ what `blank` promises about the drive.
2830
+
2831
+ ### flasheject
2832
+
2833
+ `{"t":"flasheject","ok":<bool>,"err":"busy|gone|changed|error"}`
2834
+
2835
+ The helper's eject verdict: `busy` keeps the rail's shipped "still mounted" sentence,
2836
+ `gone` names a drive that left, `changed` one that changed since it was chosen, and
2837
+ `error` everything else.
2838
+
2839
+ ### trashed
2840
+
2841
+ `{"t":"trashed","id":<uint>,"ok":<uint>,"failed":<uint>,"moved":[<string>,...]}`
2842
+
2843
+ Example: `{"t":"trashed","id":3,"ok":1,"failed":0,"moved":["/home/gm/old.txt"]}`
2844
+
2845
+ `id` echoes the request. `moved` names the original paths that went, so a client records an undo step for only those. Counts, plus the batch's own reason when anything failed: `{"t":"trashed","id":3,"ok":0,"failed":1,"err":"<string>","moved":[]}`.
2846
+ Unlike `transferitem` there is no per-path error text, because trash is one `gio` call for
2847
+ the batch and its exit status cannot attribute a failure to a single path; a path that is still on disk
2848
+ afterwards is counted in `failed`. `err` rides only on a failure, so a successful line has no `err` key.
2849
+ `gio` runs under a 10 s deadline, so a hung mount answers "The Trash took too long to answer"
2850
+ instead of holding the single operation slot.
2851
+ A batch that ran `gio` sends the line once `gio` has returned and each path has been checked on disk (`lstat`), whether
2852
+ or not every path went, so a failure goes out the same way with its `failed` count and `err`. It is sent before the second
2853
+ `gio trash --list`, which only names the undo journal's URIs. The operation slot is freed with that line: the next write
2854
+ (a second `trash` included) is accepted at once, while `undo` and `redo` answer busy naming the batch's first path until
2855
+ the list lands and the batch's undo entry is journaled, so an undo never reverses an earlier entry in its place. Entries
2856
+ journal in the order operations were accepted, so a write accepted while the list is out lands after the trash. A batch
2857
+ that ran no `gio` (every path renamed into the home Trash, or none present) sends its line once, with its journal entry.
2858
+ The line is sent once per batch.
2859
+
2860
+ ### slow
2861
+
2862
+ `{"t":"slow","op":"<string>","path":"<string>","msg":"<string>"}`
2863
+
2864
+ Example: `{"t":"slow","op":"rename","path":"/hung/a.txt","msg":"/hung is slow. The rename will finish on its own."}`
2865
+
2866
+ A remote `rename`, `mkdir`, `bulkrename` or `settags` past the single-call deadline answers this line and moves
2867
+ on: the write stays running on its own worker, so the loop keeps answering every other request,
2868
+ and the mount is never marked stuck for a write still running. `op` names the request (`rename`,
2869
+ `mkdir`, `bulkrename` or `settags`), `path` the path it is writing (the rename source, the mkdir parent, the
2870
+ bulk batch's folder or the folder of the first remote row a settags names), and `msg` the sentence the client shows as information, never as an error. The
2871
+ `slow` line releases the one-at-a-time slot at once, so writes on other mounts and on local paths
2872
+ still run; the write stays pending under its mount root instead. A write touching a mount with a
2873
+ pending slow write answers `an operation is already running` and journals nothing, and `undo` and
2874
+ `redo` answer the same while any slow write is pending, naming its path. The late reply clears
2875
+ only its own pending entry. There is still no cancel id and no progress for the running write.
2876
+ The write's own reply follows whenever it lands: the same `renamed`, `made`, `bulkrenamed` or `settagged` line the
2877
+ in-time path writes, or the same `error` line if the write failed, journalled exactly once either
2878
+ way, so one `undo` reverses it.
2879
+
2880
+ ### renamed
2881
+
2882
+ `{"t":"renamed","ok":<bool>,"path":"<string>"}`
2883
+
2884
+ Example: `{"t":"renamed","ok":true,"path":"/home/gm/new.txt"}`
2885
+
2886
+ `path` is the full path the file now has. A refusal is an `error` line instead, never this line with
2887
+ `ok` false.
2888
+
2889
+ ### duplicated
2890
+
2891
+ `{"t":"duplicated","ok":<bool>,"path":"<string>"}`
2892
+
2893
+ Example: `{"t":"duplicated","ok":true,"path":"/home/gm/photo copy.jpg"}`
2894
+
2895
+ `path` is the sibling that was written. A failure is an `error` line with `where` of `duplicate`, and a
2896
+ copy that failed partway leaves its partial sibling in place and journals it, so an `undo` removes it.
2897
+
2898
+ ### made
2899
+
2900
+ `{"t":"made","ok":<bool>,"path":"<string>"}`
2901
+
2902
+ Example: `{"t":"made","ok":true,"path":"/home/gm/New Folder"}`
2903
+
2904
+ `path` is the full path of the directory that now exists, the chosen default included when the request
2905
+ sent no `name`, which is what lets a client re-list with that row under the cursor and open its rename
2906
+ field on it. A refusal is an `error` line with `where` of `mkdir` instead, never this line with `ok`
2907
+ false.
2908
+
2909
+ ### undone
2910
+
2911
+ `{"t":"undone","op":"<string>","ok":<bool>}`
2912
+
2913
+ Example: `{"t":"undone","op":"move","ok":true}`
2914
+
2915
+ `op` is the kind of operation that was reversed, one of `rename`, `duplicate`, `mkdir`, `trash`, `copy`,
2916
+ `move` or `merge`, which is what lets the status bar say what it just put back. A copy merge journals
2917
+ `merge`, and the bar words its undo whole: "Undid the merge." Its redo refuses instead, since the
2918
+ pre-existing folder is still there. A move merge journals `move`, so its redo reads as a move.
2919
+
2920
+ ### jumped
2921
+
2922
+ `{"t":"jumped","id":<uint>,"favourites":[<string>,...],"zoxide":[<string>,...],"recent":[<string>,...],"frecency":{<string>:<float>,...},"ms":<float>}`
2923
+
2924
+ Example: `{"t":"jumped","id":3,"favourites":["/home/gm/Projects"],"zoxide":["/home/gm/Documents"],"recent":["/home/gm/Pictures/screenshots"],"frecency":{"/home/gm/Documents":80},"ms":4.210}`
2925
+
2926
+ The answer to one `jump`: the folders of each source that exist now, each source in its own order.
2927
+ A favourite or a zoxide row must itself be a directory; a recent entry stands for the folder it sits in,
2928
+ unless it is a folder itself. A folder appears once, in the first source that names it, so a favourite
2929
+ zoxide also ranks is drawn as the favourite. `frecency` is zoxide's score for every folder answered that
2930
+ zoxide ranks, whichever source draws it, and the client ranks the one list by it after the match itself.
2931
+ Anything that is not an absolute path is dropped, and so is a score that is not a finite number. `ms` is
2932
+ the whole answer's time, zoxide and the existence checks together.
2933
+
2934
+ ### devs
2935
+
2936
+ `{"t":"devs","devs":{<string>:<uint>,...},"id":<uint optional>}`
2937
+
2938
+ Example: `{"t":"devs","devs":{"/":66305,"/home":66306,"/home/gm/gone":0}}`
2939
+
2940
+ The answer to one `devs`: each asked path once, in the order asked, with its device number. A path that cannot
2941
+ be read, a missing one, an empty one, a relative one (it would name the backend's own directory, which the client
2942
+ never means) and one on a mount that is not answering all answer 0, and the client reads 0 as unknown and copies:
2943
+ copying where a move was possible is an annoyance, and moving across volumes loses the original. The line carries
2944
+ the request's positive `id` when supplied; the rail accepts only the current hover's id and path. Untagged answers
2945
+ continue to fill the breadcrumb and column device map, while tagged hover answers never enter that cache.
2946
+
2947
+ ### changed
2948
+
2949
+ `{"t":"changed","path":"<string>"}`
2950
+
2951
+ Example: `{"t":"changed","path":"/home/gm/Downloads"}`
2952
+
2953
+ **The one line no request asks for.** Every other response answers a request; this one says the
2954
+ directory the current listing came from is no longer what `list` answered with, because another
2955
+ program created, deleted, renamed, wrote or chmod'd something in it. Nothing the backend holds
2956
+ changes with it: the rows, the count and the sort order are exactly what they were, and a client
2957
+ that wants the new directory sends `list` again. `path` is the watched directory, so a client that
2958
+ has navigated since the notification was written can tell it is not about the folder it is on now,
2959
+ and drop it.
2960
+
2961
+ A `list` starts watching its path **before it reads the directory**, not after, because a change
2962
+ landing while the read runs is missing from the rows that `list` is about to answer with and is
2963
+ therefore exactly the change the client has to be told about. It is armed **beside** the watch the
2964
+ client is already on rather than in place of it, so a `list` that then fails to scan costs the
2965
+ directory still listed nothing at all: its watch was never removed, and the descriptor its own
2966
+ events carry is still the current one. `search` and `listpaths` both stop watching, because a set of
2967
+ matches and a set of named paths are not directories.
2968
+
2969
+ **A column's directory is watched too, and answers this same line.** A `peek` that carries
2970
+ `"watch":true`, which only the columns view sends, arms a non-recursive watch on its directory before
2971
+ it scans, on a second inotify descriptor of its own so that removing one can never remove the listed
2972
+ folder's watch. The watch fires the `changed` line with `path` naming the peeked directory, and the
2973
+ client re-asks that column; a client on another path drops the line as it always did. Only the
2974
+ directories the latest watching `peek` names in `"keep"` stay watched, and a peek without the flag watches nothing.
2975
+ `"keep":[...]` is every column directory the client draws, the peeked one among them: the backend unwatches every
2976
+ other peeked directory before it arms this one, and unwatches at once a directory armed after the client moved on.
2977
+ Eight directories is a guard for a client that sends no `keep`, the oldest dropped first, and a directory the
2978
+ kernel stops watching (deleted, unmounted) leaves the set after its last `changed` line.
2979
+
2980
+ The mechanism is one inotify watch on that one directory, non-recursive, with the mask
2981
+ `IN_ATTRIB | IN_CLOSE_WRITE | IN_MOVED_FROM | IN_MOVED_TO | IN_CREATE | IN_DELETE | IN_MOVE_SELF`,
2982
+ which is exactly the set of events that changes what a listing says: which names are in it, and the
2983
+ size, date and mode its columns draw. Deleting the watched directory needs no bit of its own: the
2984
+ kernel removes the watch along with it and reports that removal whatever the mask holds.
2985
+ **A file growing under an open handle is not one of them.** `IN_MODIFY` fires on every `write(2)`
2986
+ and a listing does not draw a partial size, so a row's size follows the writer closing the file
2987
+ rather than the writer writing to it.
2988
+
2989
+ **One burst is one line.** The event payload is read only far enough to name its watch descriptor,
2990
+ never for which file moved, and the reader then pauses 100 ms before reading again, so a directory
2991
+ being rewritten costs one `changed` line per 100 ms rather than one per file. Anything the kernel
2992
+ drops in that window costs nothing, because every event in a burst says the same thing to a client
2993
+ that re-reads the whole directory anyway.
2994
+
2995
+ `--backend` on a box whose inotify instance limit is exhausted prints one sentence to stderr at
2996
+ startup and then never sends this line, and a `list` whose directory the kernel refuses a watch on
2997
+ (`max_user_watches`, most often) prints one naming that directory; every other request answers
2998
+ exactly as before. A client must therefore treat a live listing as an improvement it may not get,
2999
+ not as a guarantee. A
3000
+ network mount is the other case: inotify sees the local kernel's own view of a directory, so a
3001
+ change another machine makes to an NFS or SMB share is not delivered, and Flea is stale there in
3002
+ exactly the way it was before this line existed.
3003
+
3004
+ ### plugins
3005
+
3006
+ `{"t":"plugins","id":1,"plugins":[{"id":"flea.shelf","name":"Shelf","version":"0.4.0","author":"Flea","license":"MIT","description":"...","kinds":["shelf"],"glyph":"flea","mark":"","access":["shelf:pile"],"source":"builtin","origin":"","enabled":false,"failure":"","detail":"","schema":[],"settings":{},"shelf":{"enabled":false,"bar":true,"rail":"off","screenshots":true,"recordings":true,"recent":3,"pins":[]}}]}`
3007
+
3008
+ The whole installed list after the request named by `id`; see `plugins` above. `mark` is an absolute path to the
3009
+ plugin's own SVG when it has one. `shelf` is `null` on every plugin but the Shelf.
3010
+
3011
+ ### pluginop
3012
+
3013
+ `{"t":"pluginop","id":2,"op":"add","plugin":"mtorvik.checksums","ok":true,"code":"","detail":"","version":"0.2.0"}`
3014
+
3015
+ One change's outcome. A refusal has `ok` false and a `code` with one `detail` argument; the codes are the ones
3016
+ `src/ui_logic_st2.rs` and `src/ui_logic_st2/wire.rs` list, each selecting a `plugins.error.*` or
3017
+ `plugins.invalid.*` catalog sentence. `version` is the plugin's version after an `add` or `update`.
3018
+
3019
+ ### pluginprogress
3020
+
3021
+ ```json
3022
+ {"t":"pluginprogress","id":44,"plugin":"example.send-target","message":{"op":"progress","done":1,"total":1,"message":"Receiving selection."}}
3023
+ ```
3024
+
3025
+ A progress event for the outer runtime request identified by `id` and `plugin`. The outer `message` holds the
3026
+ plugin's progress object. Its `done` and `total` are whole numbers from 0 through 9,000,000,000,000,000, with
3027
+ `done` no greater than `total`. The inner `message` is an optional string. Progress is streamed while the request
3028
+ runs, before its terminal `pluginruntime` reply. Clients should correlate events by `id` rather than count lines.
3029
+
3030
+ ### pluginruntime
3031
+
3032
+ ```json
3033
+ {"t":"pluginruntime","id":41,"plugin":"example.send-target","ok":true,"message":{"op":"targets","targets":[{"id":"receiver","label":"Local Receiver","state":"ready"},{"id":"offline","label":"Offline Receiver","state":"offline"}]},"progress":[],"code":"","error":""}
3034
+ ```
3035
+
3036
+ The terminal outcome of one runtime request. The envelope always includes `t`, `id`, `plugin`, `ok`, `message`,
3037
+ `progress`, `code` and `error`. `id` and `plugin` identify the outer request. On success, `ok` is true, `message`
3038
+ contains the result object, and `code` and `error` are empty strings. `progress` holds the accepted progress objects
3039
+ in order, including objects already sent through `pluginprogress`. Do not apply them twice.
3040
+
3041
+ For example, this send emits the progress line above and then the terminal line below:
3042
+
3043
+ ```json
3044
+ {"c":"plugins","op":"runtime","id":44,"plugin":"example.send-target","request":{"op":"send","target":"receiver","paths":["/home/me/notes.txt"]}}
3045
+ {"t":"pluginruntime","id":44,"plugin":"example.send-target","ok":true,"message":{"op":"done","message":"Selection received."},"progress":[{"op":"progress","done":1,"total":1,"message":"Receiving selection."}],"code":"","error":""}
3046
+ ```
3047
+
3048
+ A plugin's `error` result is an operation failure: `ok` is false, `code` is `operation`, `error` is the plugin's
3049
+ message string, and the original error object remains in `message`. Accepted progress is retained. This result
3050
+ does not itself mark the plugin failed.
3051
+
3052
+ ```json
3053
+ {"t":"pluginruntime","id":45,"plugin":"example.send-target","ok":false,"message":{"op":"error","message":"Choose the ready receiver and at least one file."},"progress":[],"code":"operation","error":"Choose the ready receiver and at least one file."}
3054
+ ```
3055
+
3056
+ A host refusal or runtime failure instead has `ok:false`, `message:null`, `progress:[]`, and a `code` and `error`
3057
+ describing the failure. Previously streamed progress can therefore exist even when the terminal array is empty.
3058
+ Codes include `unknown` for an unrecognized installed id, `request` for an invalid operation or its validated
3059
+ fields, `busy` for a concurrent ordinary lasting-session request or a full plugin-host worker pool, `start` for startup or
3060
+ runtime failures and for a disabled or failed plugin, and `write` when the host cannot initialize plugin storage.
3061
+ These fields are `code` and `error`, rather than the management reply's `code` and `detail`.
3062
+
3063
+ ```json
3064
+ {"t":"pluginruntime","id":46,"plugin":"example.send-target","ok":false,"message":null,"progress":[],"code":"busy","error":"plugin busy"}
3065
+ ```
3066
+
3067
+ Preview results are normalized before they reach this envelope. Text is reduced to at most 1,000 plain lines with
3068
+ control characters other than tab removed from each line. A PNG result's `png` names the retained host file, with
3069
+ `width` and `height`; it is not the subprocess's `/out/preview.png` path.
3070
+
3071
+ ### error
3072
+
3073
+ `{"t":"error","where":"<string>","path":"<string>","msg":"<string>"[,"mode":<uint>][,"code":"<string>"]}`
3074
+
3075
+ Example: `{"t":"error","where":"scan","path":"/root","msg":"permission denied","mode":16872}`
3076
+
3077
+ `where` names the failing operation, `path` names the input that failed, `msg` is the
3078
+ underlying message. `code` rides only on a refusal the UI words itself (`emptyclipboard`, from `pasteimage`). Over `--backend`'s stdout, `where` is `scan` (a `list` whose path
3079
+ failed to read), `sort` (a `sort` whose key names no order this wire defines; `path`
3080
+ carries the key as sent), `window` (a remote metadata window whose mount did not
3081
+ respond or whose worker stopped; `path` matches the current listing header's `path`
3082
+ byte for byte, including its original spelling), `stale` (a row-indexed request naming
3083
+ a numbering the listing has left; `path` carries the command, see "listing"), `flash` (a second
3084
+ flash while one runs; `path` carries the device), or `read` (the
3085
+ stdin stream itself could not be decoded; the loop stops right after emitting this
3086
+ line, because the framing cannot be trusted past that point; thumbnail work already
3087
+ running is still drained after it, so a `thumbed` line can follow). The write
3088
+ operations answer over the same stdout and name themselves the same way, and
3089
+ `rename-kept` and `rename-stranded` (see `rename`) are the only values on this wire that are not one lowercase
3090
+ word, so a client matching this field must allow the hyphen. `error_line`
3091
+ is also how `flea --prewarm` reports a failure, to stderr rather than over this wire
3092
+ protocol. `where` names whichever operation actually failed: a missing or unreadable
3093
+ `path` propagates `scan`'s error unchanged, so `where` reads `scan` there too; only a
3094
+ failure inside prewarm's own file handling (creating, writing or renaming the temp
3095
+ file) reports `where":"prewarm"`. An `error` line is sent instead of `listed` or
3096
+ `rows`; it never changes the backend process's exit code.
3097
+
3098
+ `mode` is the `st_mode` of the path a `scan` could not read, in decimal, and it is written only when
3099
+ the backend actually read it. It is a directory's mode in the case the field exists for; a `list` of
3100
+ a regular file fails with `ENOTDIR` and carries that file's mode instead, which no client draws,
3101
+ because only a denial reaches the Locked state. The stat outlives the denial: `/root` answers 16872, which is
3102
+ `0o40750`, while `opendir` on it is refused. No other `where` ever carries the field, and a `scan`
3103
+ whose path could not be stat'd either leaves it out, which a client reads as "I could not look" and
3104
+ draws as a sentence with no permission string under it. It exists because a typed path, or
3105
+ `FLEA_PATH`, reaches the denial with no parent listing to remember the mode from.
3106
+
3107
+ ## Prewarm reuses this wire format
3108
+
3109
+ `flea --prewarm <path> <first> <dest>` writes exactly a `listed` line then a `rows`
3110
+ line, the same two lines `--backend` would print for an equivalent `list`, to `dest`
3111
+ instead of stdout. See `AGENTS.md`, "Prewarm".
3112
+
3113
+ `--prewarm` exits 0 if and only if `dest` holds a complete listing of `path`; on
3114
+ failure it writes an error line to stderr, exits non-zero, leaves no temp file, and
3115
+ does not touch `dest`, so a caller must check the exit status before reading the
3116
+ file.
3117
+
3118
+ ## Undocumented requests
3119
+
3120
+ `peek`, `paths`, `archive`, `convert` and `formats` are on the wire and are not documented
3121
+ here yet. `tools/flea-acceptance` derives its checklist from this file, so each one is a gap in that
3122
+ battery until its section is written.
3123
+
3124
+ The `formats` and `archive` parts of that set are named here exactly, because they changed. A `formats` reply's `extract`
3125
+ object carries one bit per extension class: `{"archive":<bool>,"sevenZip":<bool>,"zip":<bool>}`, where
3126
+ `archive` is the tar class on `bsdtar`, `sevenZip` is the `.7z` and package (`.deb`, `.rpm`, `.apk`, `.jar`,
3127
+ `.cab`, `.cpio`, `.AppImage`) class on `7z`, and `zip` is the
3128
+ `.zip`/`.rar` class served by whichever of those two is installed. The client offers Extract on a package only
3129
+ when `sevenZip` is set; the backend's own listing of a package still falls back to `bsdtar` where `7z` is missing,
3130
+ as v0.3.8 did, except an `.AppImage`, whose squashfs image only `7z` reads. An `archive` request with an `op` of
3131
+ `extract` joins the destination-drive queue `transfer` describes. It answers `archivestarted` with its
3132
+ own id even while waiting, drives that same transfer card through `extractstarted` and
3133
+ `transferprogress`, and answers `archivedone`, never `transferdone`. A `transfercancel` naming its
3134
+ id cancels only that extraction, whether waiting or running. It is not journaled, so no `undo`
3135
+ reverses it.
3136
+
3137
+ An `archive` request is `{"c":"archive","op":"compress"|"extract","paths":[<string>,...],"path":"<string>","dest":"<string>","format":"<string>","password":"<string>","encryptNames":<bool>}`.
3138
+ A `compress` names absolute `paths` sharing one parent and the `format` to write; an `extract` names the
3139
+ one archive in `path`. `password` is empty for a plain operation: only a 7z compress or an extract that
3140
+ 7z reads (a `.zip` too, where 7z is installed) takes a non-empty one, and any other reader answers `err` of `"only an archive that 7-Zip reads
3141
+ can take a password"` without piping the secret. The secret travels this stdin wire and leaves it only on
3142
+ 7z's own stdin pipe, never in argv, an environment variable, a log or `/proc/<pid>/cmdline`. A compress
3143
+ argv carries a bare `-p` (7z then reads one stdin line), an extract carries none (7z reads a bare `-p`
3144
+ as an empty password). `encryptNames` hides the entry names of a password `.7z` (`-mhe=on`) and is
3145
+ ignored for `.zip`, which a password writes as AES-256 (`-mem=AES256`). Absent `password` is today's
3146
+ plain path, so every older client is unchanged. A plain `.zip` is written by `7z -tzip` when 7z is
3147
+ installed and by `bsdtar` otherwise, and every 7z compress stores a symbolic link as a link (`-snl`), as
3148
+ bsdtar does, so a dangling one no longer fails the archive.
3149
+
3150
+ Before a plain extract starts, an archive that 7z reads is probed with `7z l -slt` and stdin closed: exit
3151
+ 255 (the headers are encrypted) or exit 0 with an `Encrypted = +` line (entries are encrypted) answers
3152
+ `err` of `"encrypted"`, and so does a plain extract that still exits 255. A password run that exits 2
3153
+ answers `"wrong-password"` only when the same technical listing proves a password guards the archive: 7z
3154
+ exits 2 for a wrong password, for damaged encrypted data and for a file that is no archive at all, so
3155
+ without that proof the run answers as a plain failure and the card never asks again. Both are codes, never sentences, read from exit
3156
+ codes and technical listing keys and never from the tool's words; the card words its own sentence from a
3157
+ template, the client asks again on both rather than messaging, and Cancel runs nothing. A line carrying
3158
+ either code also names the retry: `{"t":"archivedone","id":<uint>,"ok":false,"verified":true,"err":"encrypted"|"wrong-password","path":"<archive>","dest":"<destination>"}`,
3159
+ so the card opens over the archive that asked and no client-side stash can name another extract's.
3160
+
3161
+ A `.iso` lists and is never extracted: an `extract` naming one answers one `archivedone` with `ok` false
3162
+ and `err` of `"a disk image opens as a listing and is never extracted"`, and starts nothing, so no `archivestarted`
3163
+ line is sent and no slot is taken. Before extracting, the archive's unpacked
3164
+ size from its own index is weighed against the destination's free space, and a fit that cannot land is
3165
+ refused the same way: `{"t":"archivedone","id":<uint>,"ok":false,"verified":true,"err":"Extracting
3166
+ <leaf> needs <needed>, and <target> has <free> free."}`, where `<target>` is the name of the folder the
3167
+ extract lands in, or `/` for the root. An index that cannot be read refuses nothing, because unknown is not evidence
3168
+ the extract cannot fit.
3169
+
3170
+ A `peek` request with `"details":true` is used by TUI folder previews. Only returned
3171
+ rows, already limited by `first` and `PEEK_CAP`, pay one no-follow metadata read
3172
+ inside the existing bounded mount worker. Their `s` (entry bytes) and `p` (mode)
3173
+ fields let the TUI draw child sizes. A vanished or unreadable entry carries zero
3174
+ mode and size, which the TUI leaves unavailable. The default is false: ancestor,
3175
+ network and other existing peeks keep their name-only path and perform no
3176
+ per-entry metadata reads.
3177
+
3178
+ One `peek` field is worth naming ahead of that section, because it is new. A `peeked` line whose scan
3179
+ failed carries `"failed":true`, with `"mode":<uint>` beside it under the same rule the `error` line
3180
+ uses, so a column can tell an unreadable directory from an empty one instead of drawing both as the
3181
+ empty state. A `peeked` line that succeeded carries neither field, whatever `n` is.
3182
+
3183
+ More `peek` fields are worth naming for the same reason. Every `peeked` line, failed or not,
3184
+ echoes the `hidden`, `hiddenLast` and `first` its request carried, because three clients read this wire at once: `ui/ColumnsArea.qml`
3185
+ peeks the pane's ancestors with the listing's own flags and the pane's window size, `ui/NetworkMounts.qml` peeks with a first of 1 or of 512,
3186
+ and the path bar's Tab peeks with whatever the typed leaf asks for. The repair legs ask 512 and the folder check asks 1. `path` alone cannot tell one client's reply from another's, and `path`
3187
+ plus `hidden` plus `hiddenLast` plus `first` can, which is all the correlation any needs: the same quad answers the same rows,
3188
+ so no request id has to be threaded through. A client that ignores the fields reads the line exactly
3189
+ as it did before. A `peeked` line echoes `by` and `desc` only when its request carried `by`, so a column re-asked after its folder's remembered order changed tells a reply sorted under the old order from the one it waits for, and a request without `by` gets the line it always got. A `peek` request carries optional `hiddenLast`, `false` unless `true`: with
3190
+ it on the peek sorts dotfiles last the way the listing does, so the columns beside a hidden-last
3191
+ listing draw the same order instead of today's dotfiles-first one. A `peek` request carries optional `by` (name, size or mtime) and `desc`, sent only by the columns view for a folder with its own remembered order while remembering is on; absent is name order, a key the wire never defined answers the same `"where":"sort"` error a `sort` does and no `peeked`, and only size and mtime pay a stat per entry. A `peek` request carries optional `watch`,
3192
+ `false` unless `true`, and `keep`, a list of paths: the columns view sets both so the backend arms a watch on that
3193
+ directory, see "A column's directory is watched too" above; the `peeked` reply is the same either way. A `peeked` line also carries `v`, the directory's device, and `w`, whether it can be written, the
3194
+ same two fields a `listed` line carries, read once per peek so a drag from a column takes the verb a drag from the
3195
+ listing takes; a failed peek carries neither.
3196
+
3197
+ ## Known gaps
3198
+
3199
+ - The two-line prewarm file names its directory, as every `listed` line does, but nothing
3200
+ in it says whether that directory changed after it was written, so a stale file cannot be
3201
+ told from a fresh one and production ignores `FLEA_PREWARM`. Add a freshness proof and
3202
+ prove a first-paint win before re-enabling a reader.
3203
+ - The pool is built when the backend starts, not when the first `thumb` arrives. That
3204
+ costs every backend process about 1.2 ms of startup and about 0.5 MB of PSS for a
3205
+ subsystem a client may never use; see AGENTS.md "Thumbnail requests".
3206
+ - `thumbcancel` names rows, and only rows this process actually queued can be cancelled.
3207
+ Cancelling a row that was answered from the cache, or that no thumbnailer declares, is
3208
+ silently a no-op, because there was never a job to drop.
3209
+ - `t` is true for a symlink whose name resolves to a declared type, whatever the link points at.
3210
+ The flag reads the file type out of the row's own `p`, which for a symlink is `S_IFLNK` and says
3211
+ nothing about the target, so a symlink to a directory, to a FIFO, to a loop, or to nothing at
3212
+ all still reports `t:true`. Every one of them is answered with an empty `file` in microseconds
3213
+ when it is actually asked for, because the request path stats the target, so nothing blocks and
3214
+ no marker is written. It is an over-promise on the wire rather than a hazard, and a client that
3215
+ renders per-row state from `t` alone will show a thumbnail slot that never fills. The shipped
3216
+ GUI renders defensively instead of the wire narrowing: an empty `file` is a terminal answer in
3217
+ its row map and the delegate falls back to the themed icon, so such a row simply keeps the icon
3218
+ it already drew. Narrowing the flag stays undone on purpose, because it is a wire change with
3219
+ its own documentation and test surface and half-narrowing it is worse than leaving it whole.
3220
+ - A non-UTF8 filename arrives lossy, and the thumbnail path inherits that. Two names in one
3221
+ directory differing only in their invalid bytes collapse to the same string, so the backend
3222
+ maps one path where the client named two rows and the second row's `thumbed` line arrives
3223
+ only at the next `list`, `sort` or shutdown. See AGENTS.md "Deliberate corners".
3224
+ - The `file` a `thumbed` line names is read from the shared cache, which every application on
3225
+ the box writes, and nothing sandboxes the client that decodes it. Generation runs under
3226
+ `bwrap`; display does not. The shipped GUI does hand this path straight to a QML `Image`, so
3227
+ Qt's PNG decoder runs unconfined in the shell process on bytes it did not write. That is a
3228
+ known residual risk, parked for a later plan rather than closed here. See AGENTS.md
3229
+ "Deliberate corners".
3230
+
3231
+ ## Jobs and Actions (0.4.0)
3232
+
3233
+ Jobs are additive to the existing transfer, archive, and LocalSend requests and replies. The backend still emits the existing single-operation events with their existing fields. Clients that do not consume jobs can ignore the new event types. Each copy, move, compression, extraction, background action, and LocalSend send has a backend job id, independent of a caller's request id. Empty Trash adopts its existing operation id and cancellation flag through the same model, with the head `Emptying Trash`.
3234
+
3235
+ Requests:
3236
+
3237
+ ```json
3238
+ {"c":"jobs","op":"list"}
3239
+ {"c":"jobs","op":"cancel","id":12}
3240
+ {"c":"jobs","op":"retry","id":12}
3241
+ {"c":"jobs","op":"dismiss","id":12}
3242
+ {"c":"jobs","op":"undo","id":12}
3243
+ ```
3244
+
3245
+ `list` returns a `jobs` snapshot. A snapshot also accompanies membership and scheduling changes. `jobs` contains the ordered list, newest first, `unfinished` counts only running and waiting jobs, and `newest` identifies the newest unfinished job or is null. `head` is that job's head, or an empty string. This supplies the TUI status row's newest operation and its `· 3 jobs` suffix. A `job` update replaces the matching id without moving its row. Progress updates are limited to one per job per 150 ms; scheduling, completion, cancellation, and Undo availability changes are immediate.
3246
+
3247
+ ```json
3248
+ {"t":"jobs","jobs":[],"unfinished":0,"newest":null,"head":""}
3249
+ {"t":"job","job":{"id":12,"kind":"copy","head":"Copying 10 of 18 to NAS","file":"DSC_4944.jpg","done":9,"total":18,"bytes":5300000,"totalBytes":9700000,"itemBytes":458300,"rate":1100000,"left":4,"state":"running","reason":"","output":[],"undo":false,"cancelled":false},"unfinished":3}
3250
+ {"t":"joberror","id":12,"error":"Only a failed job can be retried."}
3251
+ ```
3252
+
3253
+ Kinds are `copy`, `move`, `compress`, `extract`, `send`, `action`, and the adoption kind `emptyTrash`. States are `waiting`, `running`, `failed`, and `done`. `done` counts completed top-level items; `total` counts this attempt's items. Extraction starts with zero unknown entry counts until its producer supplies archive entry counts and reports unpacked bytes through `OpMsg::JobEntries`, without changing the wire. `file` is the current item's name; `itemBytes` is its known size. `bytes` is cumulative progress and `totalBytes` is the settled byte total, with zero meaning unknown. `rate` is bytes per second and `left` is estimated seconds or null when no estimate is available. A background action has null byte fields and a file-count bar. Empty Trash has null byte fields and counts successfully deleted filesystem nodes, including descendants, against the reviewed recursive total. Its initial total is zero until the worker reads the confirmation. It retains the current top-level item's reviewed size in `itemBytes`; `left` uses deleted nodes and elapsed time. Its terminal legacy replies still count top-level Trash entries, cancellation keeps completed deletions, and retry requires a fresh Trash confirmation.
3254
+
3255
+ Copies, moves, compressions, and extractions are queued in arrival order per destination filesystem. The device is resolved off the request loop, following a symlinked target. Btrfs subvolumes share the mount's source key because their anonymous `st_dev` values can differ. Separate destination filesystems run concurrently. A waiting copy uses `Copy 8 items to NAS` and names its predecessor in `reason`, for example `waiting for the copy to NAS`. Cancellation addresses only the named job, including a queued job. `transfercancel` also reaches jobs with that id, including compression. A clean shutdown cancels queued and running jobs through the existing drain.
3256
+
3257
+ A failure retains its position, with a failure head and its reason. `retry` preserves the row and id, requeues behind jobs already accepted, and narrows the attempt to failed items. The queued work retains each failed source's identity, including its birth time when available. Transfers check that identity before each item, actions before each invocation, and indivisible archive or send operations before their invocation. A replacement is refused and never becomes the identity of a subsequent retry. A compression or extraction is one indivisible failed operation. `dismiss` accepts only failed or done rows.
3258
+
3259
+ A successful journalled transfer has `undo:true` only after its journal entry is published. The offer belongs to that backend's job and expires when another operation, Undo, or Redo changes the history, including in another window. The shared journal claim compares the saved history revision and exact entry under its lock; an expired offer is refused with `That job can no longer be undone.` and revoked without consuming any entry. Reversal replies use the existing `undone` or `error` events and update search indexes and tag state through the same path as ordinary Undo, including paths changed before a partial failure. `undo` refuses while another job is unfinished. Compressions, extractions, sends, and arbitrary actions do not invent undo records. A done row is a completion notification and is omitted from the visible card; the backend retains it until the next job starts so its Undo remains addressable. Failed and done jobs do not contribute to `unfinished`.
3260
+
3261
+ Archive workers capture source identities before their first invocation and carry them with the completion, including menu selections. Completion handling never inspects those paths to construct a failed set. Later retries retain the captured identities even when a changed source is refused. Job-offer expiry uses its own shared history revision; the generation that guards Redo publication changes only on a new operation push, so overlapping successful Undos both remain redoable when no new operation was pushed.
3262
+
3263
+ Actions requests use absolute selection paths, resolved by the caller from one listing before sending. A preview request carries only the cursor file, never the marked selection.
3264
+
3265
+ ```json
3266
+ {"c":"actions","op":"list","id":4,"paths":["/home/user/Photos/photo.jpg"]}
3267
+ {"t":"actions","id":4,"actions":[{"id":"action:resize.toml","name":"Resize to 1600 px","mode":"background","selection":"many","script":false}],"errors":[],"error":""}
3268
+ {"c":"actions","op":"run","id":5,"action":"action:resize.toml","paths":["/home/user/Photos/photo.jpg"]}
3269
+ {"t":"actionstarted","requestId":5,"id":13,"ok":true,"error":""}
3270
+ {"c":"actions","op":"preview","id":6,"action":"action:describe.toml","paths":["/home/user/Photos/photo.jpg"]}
3271
+ {"t":"actionpreview","id":6,"ok":true,"text":"Description from the previewer\n","error":""}
3272
+ ```
3273
+
3274
+ A terminal handoff or refused run answers `actionstarted` with `requestId`, `ok`, and `error`, without a job id. A terminal action opens each expanded argv through `xdg-terminal-exec --dir=... -- ...`, the Open in Terminal launcher. A preview-only action refuses `run`. A preview answers text suitable for the existing `code` request's `text` input and the code renderer. Its process runs in the existing read-only preview jail with only the cursor file and executable added, a five-second wall deadline, the existing CPU and address-space limits, and a 1,048,576-byte output cap. A newer preview request cancels and suppresses the older reply.
3275
+
3276
+ The manifest directory is `$XDG_CONFIG_HOME/flea/actions` or `$HOME/.config/flea/actions`. The parser accepts the specimen's TOML subset: five unique required keys, double-quoted basic strings with JSON-compatible escapes, and possibly multiline string arrays with comments and trailing commas. Raw C0 control bytes other than tab, and raw DEL, are prohibited in strings; supported escaped values remain valid. Other keys, tables, duplicate keys, missing fields, wrong types, empty arrays, invalid modes or selection rules, invalid escapes or placeholders, and files over 16,384 bytes are refused whole. Directory discovery examines at most 257 entries to detect overflow, counting unrelated entries too, and reports when that entry limit truncates discovery; it reads at most 256 manifests; arrays hold at most 128 elements. Nonregular files are refused without blocking. Each refusal appears in the `actions.errors` array as `{"file":"bad.toml","error":"..."}`; valid siblings remain available.
3277
+
3278
+ ```toml
3279
+ name = "Resize to 1600 px"
3280
+ mime = ["image/*"]
3281
+ selection = "many"
3282
+ run = ["magick", "{path}", "-resize",
3283
+ "1600x1600>", "{dir}/{stem}-1600.{ext}"]
3284
+ mode = "background"
3285
+ ```
3286
+
3287
+ MIME patterns match the entire MIME string and accept literal characters, `*`, and `?`. Every selected item must match a pattern; `one` requires exactly one item, while `many` accepts one or more. MIME comes from the existing name database, with directories reported as `inode/directory` and unknown files as `application/octet-stream`. Listing and running recheck the actual selected paths. No directory listing is MIME-scanned to populate Actions.
3288
+
3289
+ `{path}`, `{dir}`, `{name}`, `{stem}`, and `{ext}` expand separately within each argv element, once per selected file. Replacements are not expanded again. The program is literal and cannot contain placeholders. Paths remain absolute; a placeholder-led argument whose result begins with `-` is prefixed with `./`, so a filename cannot become an option. Invocation uses argv without a shell unless the manifest explicitly names one. A background job retains only its last two merged stdout and stderr lines, each bounded to 4,096 decoded UTF-8 bytes with lossy decoding. `output` replaces the file line once nonempty. The invocation owns its entire process group: cancellation and leader exit, successful or unsuccessful, kill remaining descendants with SIGKILL before reaping the leader, including children that ignore SIGTERM.
3290
+
3291
+ Executable regular files directly under `$XDG_CONFIG_HOME/flea/scripts` remain Actions entries, with ids `script:<filename>` and labels omitting the final extension. They retain the existing Run script contract: one invocation receives all selected absolute paths. The Actions switch retains the `runScript` id in `menu.hidden` and remains hidden by default. No `src/uischema.rs` defaults change.
3292
+
3293
+ ## Checked rclone sends (0.4.0)
3294
+
3295
+ The menu's executable capabilities include `providers.rclone` with the existing `installed`, `command`, and `reason` fields. Request configured remotes only when opening the send menu:
3296
+
3297
+ ```json
3298
+ {"c":"rclone","op":"remotes","id":71}
3299
+ {"t":"rcloneremotes","id":71,"visible":true,"remotes":["gdrive","b2-photos","nas-crypt"]}
3300
+ {"c":"rclone","op":"send","id":72,"remote":"gdrive","folder":"Uploads","paths":["/absolute/photo.jpg"]}
3301
+ {"t":"rclonestarted","requestId":72,"id":12,"ok":true,"error":""}
3302
+ ```
3303
+
3304
+ `listremotes` runs off the loop, inherits `RCLONE_CONFIG`, contacts no remote and has a two-second deadline. A missing or unusable rclone, no remotes, or failed discovery answers `visible:false` and `remotes:[]`: hide the menu row. Names omit the trailing colon. Send rechecks that the remote is configured. `folder` is a path within it; empty means its configured root. A `folder` with any `..` component is refused before a job starts. Source paths must be absolute and are resolved once by the caller. A refused request answers `rclonestarted` with `requestId`, `ok:false`, `error`, and no job id.
3305
+
3306
+ A send uses the ordinary jobs list, `kind:"send"`, with independent per-item `copyto` or directory `copy` invocations followed by a separate verification pass using `check --one-way`. Source and target argv follow `--`; no shell is involved. Directory copies preserve the selected directory and empty descendants. A remote that cannot store empty directories refuses any selected tree containing one instead of issuing a checked receipt. Selected symlinks, unsupported item kinds and duplicate destination basenames are refused. Directory descendants are inspected without following links; symlinks, pipes, sockets and other unsupported descendants fail the item with their path. A skip reported during copy or check also fails the item, even when rclone exits successfully. A nonempty remote folder ending in a colon still separates its selected basename with a slash. Source identities are retained through retries and revalidated before every invocation. Originals are never removed.
3307
+
3308
+ Job updates add `phase`, `check`, `receipt`, and `note` strings, empty for existing producers. Rclone phases are `sending`, `checking`, `checked`, and `finished`; checks are `none`, `checksum`, and `size`. Sending uses the existing counts and byte fields, with monotonic absolute JSON-stat bytes and rclone's speed and ETA. Checking remains `state:"running"`, with a heading such as `Checking 3 items on gdrive`, `file:"comparing checksums"` or `file:"comparing sizes"`, an indeterminate bar and no rendered byte line. Numeric byte fields retain their prior values. State and phase changes are immediate; ordinary progress remains limited to one update per 150 ms.
3309
+
3310
+ Inherited global copy, filter and reporting options, including quiet/verbose and local traversal aliases, are removed from rclone children, while configuration, authentication and unrelated backend options remain inherited. Copy and check explicitly disable link following, link translation, filesystem-boundary filtering and silent skip settings, overriding backend and per-remote traversal settings. A checked receipt covers the explicitly selected content. Remote features are queried before copying and determine available hashes and empty-directory support; a remote with no locally supported hash uses size-only verification. If an individual object cannot supply a hash, the receipt is downgraded to size. Only a successful copy and completed check produce a verified receipt. The terminal notification follows the terminal job update:
3311
+
3312
+ ```json
3313
+ {"t":"rclonesent","id":12,"requestId":72,"ok":true,"cancelled":false,"check":"checksum","receipt":"Sent 3 items to Uploads on gdrive","note":"","error":""}
3314
+ ```
3315
+
3316
+ The receipt has no full stop; clients append muted ` · checked by checksum` or ` · checked by size`. A mismatch fails the job with `Send failed` and a reason naming the failed file and remote. The existing jobs Cancel kills the current process group, including descendants that ignore SIGTERM. Files already copied remain remote; a cancelled check has `check:"none"`, `note:"not checked"`, and `ok:false`. Retry keeps the id and row and resends only failed top-level items with their captured identities. It forces retransmission so a checksum failure can be repaired even when the destination retains the source size and timestamp. Retry counts describe that attempt. Dismiss uses the ordinary job contract. Sends never offer Undo.
3317
+
3318
+ ## Contents search
3319
+
3320
+ `searchcontents` takes `path` (an absolute folder), `query` (one nonempty literal,
3321
+ case-sensitive line), `hidden`, positive `gen` and optional `count`. The default
3322
+ count is the existing search first-window size; counts above 4096 are refused.
3323
+ The producer runs ripgrep once in the read-only sandbox, honors ignore files,
3324
+ does not follow discovered symlinks, skips binary matches, and keeps the first
3325
+ matching line per file. No shell interprets the query or path.
3326
+
3327
+ The previous complete listing remains current until ripgrep finishes successfully.
3328
+ The new snapshot emits `contentsmatches` for the first window, then ordinary
3329
+ `listed`, `rows`, and terminal `searched`. All carry the query generation;
3330
+ `contentsmatches`, `listed` and `rows` also carry the operation `listing` identity.
3331
+ `window` emits its matching metadata before its rows, retaining both identities.
3332
+ The complete result set remains pageable. Match metadata may use several bounded
3333
+ envelopes for a window; each envelope's `start` and contiguous `row` values name
3334
+ its portion. Consumers assemble metadata and rows before presenting the snapshot.
3335
+
3336
+ ```json
3337
+ {"c":"searchcontents","path":"/work","query":"needle","hidden":false,"gen":7,"count":40}
3338
+ {"t":"contentsmatches","gen":7,"listing":2,"start":0,"matches":[{"row":0,"path":"notes.txt","pathBytes":"6e6f7465732e747874","lineNo":3,"line":"needle first","operable":true}]}
3339
+ ```
3340
+
3341
+ `pathBytes` is hexadecimal encoding of the exact relative filename bytes.
3342
+ `path` is display text only. Line numbers are one-based. Non-UTF-8 paths retain
3343
+ their lossy display, matching the previous listing behavior, but carry
3344
+ `operable:false`. A real UTF-8 filename sharing that display also carries false
3345
+ while this snapshot is held. Backend row and explicit-path requests for either
3346
+ ambiguous identity are refused with `where:"contentsidentity"` before resolution.
3347
+ An adapter must refuse external open, reveal, clipboard, drag and operation
3348
+ dispatch for these rows; it must never construct an operation path from display
3349
+ text. The shared `ui_logic_sr1` parser validates metadata for both front ends.
3350
+ This producer does not enable either adapter's contents capability.
3351
+
3352
+ `searchcancel` and `searchclose` stop and reap the running jail, including startup.
3353
+ Their cancellation or close receipt follows cleanup and names the newest requested
3354
+ generation, including a request waiting for an older worker to stop. Older or
3355
+ duplicate generations and stale completion tickets publish nothing. Replacement
3356
+ queries serialize after cleanup. Navigation discards the old worker's result.
3357
+ A file or folder that ripgrep cannot open or read, the root included, is skipped
3358
+ as the names walk skips it: when ripgrep exits 2 after writing its summary and
3359
+ every line it wrote to stderr names a path with its OS error (`(os error 13)`),
3360
+ the snapshot publishes what it read and the terminal `searched` carries
3361
+ `partial:true` with `partial_reason:"unreadable"`. Any other stderr line makes the
3362
+ exit a failure.
3363
+ On missing ripgrep, a sandbox that refuses to start, any other exit failure,
3364
+ malformed output, output limits or the 30-second wall deadline, the backend emits
3365
+ a generation-keyed `error` and preserves the previous listing, with no success
3366
+ receipt. The GUI ends that generation's walk with nothing found and says the
3367
+ error's sentence in the footer.
3368
+ Retained match data and a single pending rg JSON record each have an independent
3369
+ 64 MiB bound. Both stores may coexist, in addition to transient parser and wire
3370
+ allocations. Exceeding either bound fails the request rather than returning an
3371
+ apparently complete cap. Full matching lines within these bounds are retained.
3372
+ The worker reads at most 8 KiB per pipe iteration. A finished search ignores
3373
+ `searchcancel`; `searchclose` acknowledges its scope once. Closing retains the
3374
+ displayed snapshot and its identity guard until navigation or replacement, so
3375
+ closing cannot make an ambiguous filename operable. Failed or cancelled queries
3376
+ also preserve the previous folder's watch and any previous name-search session.
3377
+ An explicit `searchclose` closes that previous names session and walk too, before
3378
+ the contents close receipt; late names completion events then publish nothing.
3379
+ Sorting a snapshot reorders match metadata with its listing and advances the
3380
+ listing identity. Name and kind sorts work for every snapshot; size and date
3381
+ sorts refuse snapshots containing non-operable paths to avoid statting aliases.
3382
+
3383
+ ### flea-qml shared Trash listing
3384
+
3385
+ `{"c":"listtrash","first":100}` responds with ordinary `listed` and `rows`
3386
+ messages. Rows name actual stored files relative to `/` (absolute drive paths
3387
+ on Windows), with `trash: {original, deleted, identity}` display metadata.
3388
+ `trashbrowse` restore/prepare may use `rows` plus `listing`; a stale listing
3389
+ is rejected before row indices are translated to captured Trash identities.
3390
+ The existing review token still protects permanent deletion.
3391
+
3392
+ ### flea-qml probed places
3393
+
3394
+ The sidebar's folders and drives are found by looking at the filesystem; no
3395
+ outside program runs. The backend sends one line at start, one for every
3396
+ `{"c":"places"}` request, and one whenever a watched answer changes:
3397
+
3398
+ `{"t":"places","places":[{"path":"/sdcard","label":"Internal storage","glyph":"smartphone"}],"volumes":[{"path":"/","label":"/","glyph":"drive","size":1002836697088}]}`
3399
+
3400
+ `places` are drawn under PLACES after Home, `volumes` under DEVICES; a volume's
3401
+ `size` is its filesystem's byte size or `null`. `{"c":"places","watch":true}`
3402
+ also looks again every five seconds until `{"c":"places","watch":false}`,
3403
+ which is not answered. A rail sends the first while it is shown and the second
3404
+ when it is parked or unloaded, so nothing looks while no rail asks. One look
3405
+ at a path is awaited for two seconds; a path that has not answered by then is
3406
+ left out of that line and joins a later one.
3407
+
3408
+ Rows, in order: the folders under home (`user-dirs.dirs`' path for a role
3409
+ first, then Desktop, Downloads or Download, Documents, Pictures, DCIM/Camera
3410
+ or DCIM or Pictures/Camera Roll, Pictures/Screenshots or DCIM/Screenshots,
3411
+ Music, Videos or Movies), each only when it directly holds a regular file;
3412
+ on Android, told by `/system/bin/linker64` being there, `/sdcard` and
3413
+ `/sdcard/Download` always, the same folders under `/sdcard` by the same rule,
3414
+ and, when the temporary directory lies in `/data/data/PACKAGE` or
3415
+ `/data/user/N/PACKAGE`, `/sdcard/Android/media/PACKAGE` (made once per run when
3416
+ missing) and that private folder; then each of `os.tmpdir()`, `$TMPDIR` and
3417
+ `/tmp` that is a folder. Volumes: every root that can be listed (`/`, or the
3418
+ drive letters where paths have them), then what is mounted under `/Volumes`,
3419
+ `/media`, `/run/media` and, on Android, `/storage` beside `emulated` and
3420
+ `self`. One folder is one row however many names reach it.