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,154 @@
1
+ import { test, expect, afterAll } from 'bun:test';
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { deflateRawSync } from 'node:zlib';
6
+ import { officeKind, officePreview } from '../src/office.js';
7
+ import { html, previewHtml } from '../src/html.js';
8
+ import { code } from '../src/code.js';
9
+
10
+ const dir = mkdtempSync(join(tmpdir(), 'flea-office-'));
11
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
12
+
13
+ // A zip of the given members, deflated unless `store`; CRCs are zero because nothing here checks them.
14
+ function zip(members, store = false) {
15
+ const locals = [], centrals = [];
16
+ let offset = 0;
17
+ for (const [name, content] of Object.entries(members)) {
18
+ const raw = Buffer.from(content), data = store ? raw : deflateRawSync(raw), id = Buffer.from(name);
19
+ const local = Buffer.alloc(30);
20
+ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(store ? 0 : 8, 8);
21
+ local.writeUInt32LE(data.length, 18); local.writeUInt32LE(raw.length, 22); local.writeUInt16LE(id.length, 26);
22
+ const central = Buffer.alloc(46);
23
+ central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6);
24
+ central.writeUInt16LE(store ? 0 : 8, 10); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(raw.length, 24);
25
+ central.writeUInt16LE(id.length, 28); central.writeUInt32LE(offset, 42);
26
+ locals.push(local, id, data); centrals.push(central, id);
27
+ offset += 30 + id.length + data.length;
28
+ }
29
+ const directory = Buffer.concat(centrals), end = Buffer.alloc(22);
30
+ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(Object.keys(members).length, 8); end.writeUInt16LE(Object.keys(members).length, 10);
31
+ end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);
32
+ return Buffer.concat([...locals, directory, end]);
33
+ }
34
+ const file = (name, bytes) => { const path = join(dir, name); writeFileSync(path, bytes); return path; };
35
+
36
+ const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
37
+ const docx = {
38
+ 'word/styles.xml': `<w:styles ${W}><w:style w:type="paragraph" w:styleId="Kop1"><w:name w:val="heading 1"/></w:style><w:style w:type="paragraph" w:styleId="T"><w:name w:val="Title"/></w:style></w:styles>`,
39
+ 'word/numbering.xml': `<w:numbering ${W}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/></w:lvl></w:abstractNum><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num><w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num></w:numbering>`,
40
+ 'word/document.xml': `<?xml version="1.0"?><w:document ${W}><w:body>
41
+ <w:p><w:pPr><w:pStyle w:val="Kop1"/><w:rPr><w:b/></w:rPr></w:pPr><w:r><w:t>Report &amp; plan</w:t></w:r></w:p>
42
+ <w:p><w:r><w:t xml:space="preserve">Plain </w:t></w:r><w:r><w:rPr><w:b/><w:i w:val="0"/></w:rPr><w:t>bold</w:t></w:r><w:r><w:rPr><w:i/><w:u w:val="single"/></w:rPr><w:t> a&lt;b</w:t></w:r><w:r><w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:t>2</w:t></w:r></w:p>
43
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>one</w:t></w:r></w:p>
44
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>two</w:t></w:r></w:p>
45
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>first</w:t></w:r></w:p>
46
+ <w:tbl><w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p><w:p><w:r><w:t>line</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
47
+ <w:p><w:r><w:t>kept</w:t></w:r><w:del><w:r><w:delText>removed</w:delText></w:r></w:del><w:r><w:instrText>PAGE</w:instrText></w:r><w:r><w:br/><w:t>next</w:t><w:tab/><w:t>col</w:t></w:r></w:p>
48
+ <w:p/>
49
+ </w:body></w:document>`,
50
+ };
51
+
52
+ test('office names are told apart by extension', () => {
53
+ expect(['a.DOCX', 'b.odt', 'c.pptx', 'd.odp', 'e.xlsx', 'f.ods', 'g.doc', 'h.zip', 'docx'].map(officeKind))
54
+ .toEqual(['document', 'document', 'slides', 'slides', 'sheet', 'sheet', '', '', '']);
55
+ });
56
+
57
+ test('DOCX becomes headings, emphasis, lists and tables in the fixed tags', () => {
58
+ const preview = officePreview(file('rich.docx', zip(docx)));
59
+ expect(preview.listed).toBe(false);
60
+ expect(preview.html).toBe('<h1>Report &amp; plan</h1><p>Plain <b>bold</b><u><i> a&lt;b</i></u><sup>2</sup></p>'
61
+ + '<ul><li>one</li><li>two</li></ul><ol><li>first</li></ol>'
62
+ + '<table><tr><td>A1<br>line</td><td>B1</td></tr></table>'
63
+ + '<p>kept<br>next&nbsp;&nbsp;&nbsp;&nbsp;col</p>');
64
+ // The fragment survives the HTML preview's own rebuild unchanged, so every tag is one it keeps.
65
+ expect(previewHtml(preview.html)).toBe(preview.html);
66
+ expect(preview.text).toBe('Report & plan\nPlain bold a<b2\none\ntwo\nfirst\nA1\nline\tB1\n\nkept\nnext col\n');
67
+ });
68
+
69
+ test('the html and code requests answer a DOCX from its text, stored or deflated', async () => {
70
+ const path = file('stored.docx', zip(docx, true));
71
+ expect(await html({ path, token: 3, slot: 'column' })).toMatchObject({ state: 'ready', token: 3, total: 9, html: officePreview(path).html });
72
+ const reply = await code({ path, first: 1, count: 2 });
73
+ expect(reply).toMatchObject({ state: 'plain', total: 10, lines: [[0, 'Report & plan'], [0, 'Plain bold a<b2']] });
74
+ });
75
+
76
+ test('PPTX slides follow the presentation order, with titles and tables', () => {
77
+ const slide = (title, body) => `<p:sld><p:sp><p:nvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp>`
78
+ + `<p:sp><p:nvSpPr><p:nvPr><p:ph idx="1"/></p:nvPr></p:nvSpPr><p:txBody><a:p><a:r><a:rPr b="1"/><a:t>${body}</a:t></a:r><a:br/><a:r><a:t>more</a:t></a:r></a:p></p:txBody></p:sp></p:sld>`;
79
+ const preview = officePreview(file('deck.pptx', zip({
80
+ 'ppt/presentation.xml': '<p:presentation><p:sldIdLst><p:sldId id="1" r:id="rId2"/><p:sldId id="2" r:id="rId1"/></p:sldIdLst></p:presentation>',
81
+ 'ppt/_rels/presentation.xml.rels': '<Relationships><Relationship Id="rId1" Target="slides/slide1.xml"/><Relationship Id="rId2" Target="slides/slide2.xml"/></Relationships>',
82
+ 'ppt/slides/slide1.xml': slide('Second', 'b'),
83
+ 'ppt/slides/slide2.xml': slide('First', 'a'),
84
+ })));
85
+ expect(preview.html).toBe('<h2>Slide 1</h2><h3>First</h3><p><b>a</b><br>more</p><h2>Slide 2</h2><h3>Second</h3><p><b>b</b><br>more</p>');
86
+ });
87
+
88
+ test('ODT reads headings, styled spans, lists and tables, without tracked changes or notes', () => {
89
+ const preview = officePreview(file('text.odt', zip({ 'content.xml': `<office:document-content>
90
+ <office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style></office:automatic-styles>
91
+ <office:body><office:text><text:tracked-changes><text:p>gone</text:p></text:tracked-changes>
92
+ <text:h text:outline-level="2">Head</text:h>
93
+ <text:p>a<text:span text:style-name="T1">b</text:span><text:s text:c="2"/>c<text:note><text:p>foot</text:p></text:note><text:line-break/>d</text:p>
94
+ <text:list><text:list-item><text:p>x</text:p></text:list-item><text:list-item><text:p>y</text:p></text:list-item></text:list>
95
+ <table:table><table:table-row><table:table-cell><text:p>1</text:p></table:table-cell><table:table-cell><text:p>2</text:p></table:table-cell></table:table-row></table:table>
96
+ </office:text></office:body></office:document-content>` })));
97
+ expect(preview.html).toBe('<h2>Head</h2><p>a<i><b>b</b></i>&nbsp;&nbsp;c<br>d</p><ul><li>x</li><li>y</li></ul><table><tr><td>1</td><td>2</td></tr></table>');
98
+ });
99
+
100
+ test('XLSX answers its first sheet as CSV: shared strings, dates, booleans, gaps and quoting', async () => {
101
+ const path = file('book.xlsx', zip({
102
+ 'xl/workbook.xml': '<workbook><sheets><sheet name="Second" sheetId="2" r:id="rId2"/><sheet name="Other" sheetId="1" r:id="rId1"/></sheets></workbook>',
103
+ 'xl/_rels/workbook.xml.rels': '<Relationships><Relationship Id="rId1" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Target="/xl/worksheets/sheet2.xml"/></Relationships>',
104
+ 'xl/sharedStrings.xml': '<sst><si><t>Name</t></si><si><r><t>Say </t></r><r><t>"hi", ok</t></r><rPh><t>skip</t></rPh></si></sst>',
105
+ 'xl/styles.xml': '<styleSheet><numFmts><numFmt numFmtId="164" formatCode="yyyy\\-mm\\-dd"/></numFmts><cellStyleXfs><xf numFmtId="14"/></cellStyleXfs><cellXfs><xf numFmtId="0"/><xf numFmtId="164"/><xf numFmtId="20"/></cellXfs></styleSheet>',
106
+ 'xl/worksheets/sheet1.xml': '<worksheet><sheetData><row r="1"><c r="A1"><v>wrong sheet</v></c></row></sheetData></worksheet>',
107
+ 'xl/worksheets/sheet2.xml': '<worksheet><sheetData>'
108
+ + '<row r="1"><c r="A1" t="s"><v>0</v></c><c r="C1" t="inlineStr"><is><t>When</t></is></c></row>'
109
+ + '<row r="2"><c r="A2" t="s"><v>1</v></c><c r="B2"><v>1.50</v></c><c r="C2" s="1"><v>45000</v></c><c r="D2" t="b"><v>1</v></c><c r="E2" s="2"><v>0.5</v></c></row>'
110
+ + '<row r="4"><c r="B4" t="str"><f>A1</f><v>calc</v></c></row>'
111
+ + '</sheetData></worksheet>',
112
+ }));
113
+ const preview = officePreview(path);
114
+ expect(preview.csv).toBe('Name,,When\n"Say ""hi"", ok",1.5,2023-03-15,TRUE,12:00:00\n\n,calc\n[Sheet 1 of 2: Second. The other sheets are not shown.]\n');
115
+ // The table asks by record; the quoted record and the count come back whole.
116
+ expect(await code({ path, raw: true, csv: ',', record: 2, count: 1 })).toMatchObject({ first: 2, records: 5, firstRecord: 2, lines: [[0, '"Say ""hi"", ok",1.5,2023-03-15,TRUE,12:00:00']] });
117
+ });
118
+
119
+ test('ODS repeats filled cells and ignores the padding to the sheet edge', () => {
120
+ const preview = officePreview(file('sheet.ods', zip({ 'content.xml': `<office:document-content><office:body><office:spreadsheet>
121
+ <table:table table:name="S"><table:table-row><table:table-cell table:number-columns-repeated="2"><text:p>x</text:p></table:table-cell><table:table-cell table:number-columns-repeated="16382"/></table:table-row>
122
+ <table:table-row table:number-rows-repeated="2"><table:table-cell/></table:table-row>
123
+ <table:table-row><table:table-cell/><table:table-cell><text:p>a, b</text:p><text:p>c</text:p></table:table-cell></table:table-row>
124
+ <table:table-row table:number-rows-repeated="1048570"><table:table-cell table:number-columns-repeated="16384"/></table:table-row></table:table>
125
+ </office:spreadsheet></office:body></office:document-content>` })));
126
+ expect(preview.csv).toBe('x,x\n\n\n,"a, b\nc"\n');
127
+ });
128
+
129
+ test('a container with no readable content lists its members, and a non-zip is refused', async () => {
130
+ const empty = officePreview(file('empty.docx', zip({ '[Content_Types].xml': '<Types/>', 'word/other.xml': '<x/>' })));
131
+ expect(empty).toMatchObject({ listed: true, html: '<p>No readable text in this document. It contains:</p><ul><li>[Content_Types].xml</li><li>word/other.xml</li></ul>' });
132
+ const sheet = officePreview(file('empty.xlsx', zip({ 'a&b.bin': 'xyz' })));
133
+ expect(sheet).toMatchObject({ listed: true, csv: 'Member,Bytes\na&b.bin,3\n' });
134
+ const legacy = file('legacy.docx', Buffer.from('\xd0\xcf\x11\xe0 not a zip', 'latin1'));
135
+ expect(() => officePreview(legacy)).toThrow();
136
+ expect((await html({ path: legacy, token: 1, slot: 'x' })).state).toBe('failed');
137
+ expect((await code({ path: legacy })).state).toBe('failed');
138
+ });
139
+
140
+ test('hostile XML is neither expanded nor passed through', () => {
141
+ const bomb = officePreview(file('bomb.docx', zip({ 'word/document.xml': '<!DOCTYPE d [<!ENTITY a "aaaa">]><w:document><w:body><w:p><w:r><w:t>&a;</w:t></w:r></w:p></w:body></w:document>' })));
142
+ expect(bomb.listed).toBe(true);
143
+ const markup = officePreview(file('markup.docx', zip({ 'word/document.xml': '<w:document><w:body><w:p><w:r><w:t>&lt;script&gt;x&lt;/script&gt; &#60;img&#62; &amp;amp;</w:t></w:r></w:p></w:body></w:document>' })));
144
+ expect(markup.html).toBe('<p>&lt;script&gt;x&lt;/script&gt; &lt;img&gt; &amp;amp;</p>');
145
+ expect(previewHtml(markup.html)).toBe(markup.html);
146
+ });
147
+
148
+ test('a long document stops at the output limit with balanced tags', () => {
149
+ const paragraph = `<w:p><w:r><w:t>${'word '.repeat(200)}</w:t></w:r></w:p>`;
150
+ const preview = officePreview(file('long.docx', zip({ 'word/document.xml': `<w:document><w:body>${paragraph.repeat(2000)}</w:body></w:document>` })));
151
+ expect(preview.html.length).toBeLessThan(1 << 20);
152
+ expect(preview.html.endsWith('<p>[Preview truncated]</p>')).toBe(true);
153
+ expect(previewHtml(preview.html)).toBe(preview.html);
154
+ });
@@ -0,0 +1,28 @@
1
+ import {expect, test} from 'bun:test';
2
+ import {mermaidToSvg} from '../src/pele-wrapper.js';
3
+ import fixtures from './fixtures/pele-parity.json';
4
+
5
+ // Baseline: the former Flea bundle (SHA-256
6
+ // 6030688a4dc97db8eee282af325343a8de1ae300f862a6f5c9d9175d40c8b765).
7
+ // Store only inputs and output hashes, not a second copy of the renderer.
8
+ // 33 inputs come from Flea's tests/fixtures/markdown-mermaid at 5da918109:
9
+ // Flea/Pele/Mermaid MIT examples; see THIRD-PARTY-NOTICES.md.
10
+ for (const entry of fixtures.cases) {
11
+ test(`Pele preserves legacy output: ${entry.name}`, () => {
12
+ for (const [index, theme] of fixtures.themes.entries()) {
13
+ const expected = entry.results[index];
14
+ if (expected.error) {
15
+ let error;
16
+ try {mermaidToSvg(entry.source, theme);} catch (e) {error = e.message;}
17
+ expect(error).toBe(expected.error);
18
+ } else {
19
+ const svg = mermaidToSvg(entry.source, theme);
20
+ expect(new Bun.CryptoHasher('sha256').update(svg).digest('hex')).toBe(expected.sha256);
21
+ }
22
+ }
23
+ });
24
+ }
25
+ test('Pele is the unmodified official 1.6.0 ESM distribution', async () => {
26
+ const bytes = await Bun.file(new URL('../src/vendor/pele.js', import.meta.url)).arrayBuffer();
27
+ expect(new Bun.CryptoHasher('sha256').update(bytes).digest('hex')).toBe('d1ebc1889f3069d7759fe5db34c4ad4b4823285edbee2b537d0ef3d505a76d8d');
28
+ });
@@ -0,0 +1,134 @@
1
+ import { test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, posix, win32 } from "node:path";
5
+ import { appPackage, places, probePlaces, rootNames, userDirs } from "../src/places.js";
6
+
7
+ let root, home;
8
+ // Nothing of the machine running the tests is read: every source is named here.
9
+ const quiet = (extra = {}) => ({ home, env: {}, android: false, temps: [], roots: [], mounts: [], ...extra });
10
+ const folder = (...parts) => {
11
+ const path = join(root, ...parts);
12
+ mkdirSync(path, { recursive: true });
13
+ return path;
14
+ };
15
+ const file = (...parts) => {
16
+ folder(...parts.slice(0, -1));
17
+ writeFileSync(join(root, ...parts), "x");
18
+ };
19
+ const labels = (rows) => rows.map((row) => row.label);
20
+
21
+ beforeEach(() => {
22
+ root = mkdtempSync(join(tmpdir(), "flea-places-"));
23
+ home = folder("home");
24
+ });
25
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
26
+
27
+ test("user-dirs.dirs names a role's folder, the last valid line winning", () => {
28
+ const dirs = userDirs(
29
+ ['XDG_DOWNLOAD_DIR="$HOME/Old"', ' XDG_DOWNLOAD_DIR = "$HOME/下載" # inbox', 'XDG_MUSIC_DIR="/srv/music/"', 'XDG_PICTURES_DIR="relative"', 'XDG_VIDEOS_DIR="$HOME/a\\"b"', "XDG_DESKTOP_DIR=$HOME/Desktop"].join("\n"),
30
+ "/home/a",
31
+ );
32
+ expect(Object.fromEntries(dirs)).toEqual({ DOWNLOAD: "/home/a/下載", MUSIC: "/srv/music", VIDEOS: '/home/a/a"b' });
33
+ });
34
+
35
+ test("an Android app is read off the temporary directory inside its private folder", () => {
36
+ expect(appPackage("/data/data/com.termux/files/usr/tmp")).toEqual({ name: "com.termux", dir: "/data/data/com.termux" });
37
+ expect(appPackage("/data/user/0/org.example.app_2/cache")).toEqual({ name: "org.example.app_2", dir: "/data/user/0/org.example.app_2" });
38
+ expect(appPackage("/data/user/10/a.b")).toEqual({ name: "a.b", dir: "/data/user/10/a.b" });
39
+ for (const other of ["/tmp", "/data/local/tmp", "/data/data/nodots/tmp", "/data/user/x/com.a.b/tmp", "/data/data/com.1a/tmp", "/sdcard/data/data/com.a.b", "", undefined])
40
+ expect(appPackage(other)).toBeNull();
41
+ });
42
+
43
+ test("the roots are / where paths are POSIX and the drive letters where they are not", () => {
44
+ expect(rootNames(posix)).toEqual(["/"]);
45
+ const letters = rootNames(win32);
46
+ expect(letters).toContain("C:\\");
47
+ expect(letters.filter((name) => /^[A-Z]:\\$/.test(name)).length).toBe(26);
48
+ expect(letters).not.toContain("/");
49
+ });
50
+
51
+ test("a folder is a place only once it directly holds a regular file", async () => {
52
+ file("home", "Documents", "note.txt");
53
+ file("home", "Downloads", "setup.bin");
54
+ folder("home", "Music");
55
+ folder("home", "Pictures", "Album");
56
+ file("home", "Pictures", "Album", "deep.jpg");
57
+ folder("home", "Videos");
58
+ symlinkSync(join(home, "Documents", "note.txt"), join(home, "Videos", "link.txt"));
59
+ const found = await probePlaces(quiet());
60
+ expect(found.places).toEqual([
61
+ { path: join(home, "Downloads"), label: "Downloads", glyph: "download" },
62
+ { path: join(home, "Documents"), label: "Documents", glyph: "file-text" },
63
+ ]);
64
+ expect(found.volumes).toEqual([]);
65
+ // The first file to land makes the row, and the folder's own change is what is noticed.
66
+ writeFileSync(join(home, "Music", "song.ogg"), "x");
67
+ expect(labels((await probePlaces(quiet())).places)).toEqual(["Downloads", "Documents", "Music"]);
68
+ });
69
+
70
+ test("user-dirs.dirs is tried before the names, and one folder is one row", async () => {
71
+ file("home", "文件", "a.txt");
72
+ file("home", "Documents", "b.txt");
73
+ file("home", "Movies", "c.mkv");
74
+ file("home", "Shared", "d.txt");
75
+ symlinkSync(join(home, "Movies"), join(home, "Videos"));
76
+ folder("home", ".config");
77
+ writeFileSync(join(home, ".config", "user-dirs.dirs"), 'XDG_DOCUMENTS_DIR="$HOME/文件"\nXDG_PUBLICSHARE_DIR="$HOME/Shared"\nXDG_TEMPLATES_DIR="$HOME"\n');
78
+ const found = await probePlaces(quiet());
79
+ expect(found.places).toEqual([
80
+ { path: join(home, "文件"), label: "文件", glyph: "file-text" },
81
+ { path: join(home, "Videos"), label: "Videos", glyph: "film" },
82
+ { path: join(home, "Shared"), label: "Shared", glyph: "folder" },
83
+ ]);
84
+ });
85
+
86
+ test("temporary folders are probed and named once each", async () => {
87
+ const a = folder("tmp-a"),
88
+ b = folder("tmp-b");
89
+ symlinkSync(a, join(root, "tmp-link"));
90
+ const found = await probePlaces(quiet({ temps: [a, undefined, join(root, "tmp-link"), join(root, "missing"), "relative", b] }));
91
+ expect(found.places).toEqual([
92
+ { path: a, label: "Temp", glyph: "folder" },
93
+ { path: b, label: b, glyph: "folder" },
94
+ ]);
95
+ });
96
+
97
+ test("Android always has its shared storage and Download, and the app's own two folders", async () => {
98
+ const shared = join(root, "shared");
99
+ file("shared", "DCIM", "Camera", "IMG_1.jpg");
100
+ file("shared", "DCIM", "stray.jpg");
101
+ file("shared", "Pictures", "Screenshots", "s.png");
102
+ folder("shared", "Documents");
103
+ const app = folder("data", "data", "com.example.app");
104
+ const found = await probePlaces(quiet({ android: true, shared, temps: ["/data/user/0/com.example.app/cache"] }));
105
+ expect(found.places).toEqual([
106
+ { path: shared, label: "Internal storage", glyph: "smartphone" },
107
+ { path: join(shared, "Download"), label: "Download", glyph: "download" },
108
+ { path: join(shared, "DCIM", "Camera"), label: "Camera", glyph: "camera" },
109
+ { path: join(shared, "Pictures", "Screenshots"), label: "Screenshots", glyph: "image" },
110
+ { path: join(shared, "Android", "media", "com.example.app"), label: "App media", glyph: "folder" },
111
+ ]);
112
+ expect(existsSync(join(shared, "Android", "media", "com.example.app"))).toBe(true);
113
+ expect(existsSync(app)).toBe(true);
114
+ // Without a camera folder holding files, DCIM itself is the camera's row.
115
+ rmSync(join(shared, "DCIM", "Camera"), { recursive: true });
116
+ expect(labels((await probePlaces(quiet({ android: true, shared }))).places)).toEqual(["Internal storage", "Download", "DCIM", "Screenshots"]);
117
+ });
118
+
119
+ test("a root that can be listed is a volume with its size, and one that cannot is not", async () => {
120
+ const found = await probePlaces(quiet({ roots: [root, join(root, "gone"), root + "/"] }));
121
+ expect(found.volumes.length).toBe(1);
122
+ expect(found.volumes[0]).toMatchObject({ path: root, label: root, glyph: "drive" });
123
+ expect(found.volumes[0].size).toBeGreaterThan(0);
124
+ });
125
+
126
+ test("the places request answers when asked, and a watch is ended by its own word", async () => {
127
+ file("home", "Documents", "note.txt");
128
+ const lines = [];
129
+ await places({}, (line) => lines.push(line), quiet());
130
+ await places({ watch: true }, (line) => lines.push(line), quiet());
131
+ await places({ watch: false }, (line) => lines.push(line), quiet());
132
+ expect(lines.length).toBe(2);
133
+ expect(lines[0]).toEqual({ t: "places", places: [{ path: join(home, "Documents"), label: "Documents", glyph: "file-text" }], volumes: [] });
134
+ });
@@ -0,0 +1,60 @@
1
+ import { test, expect } from 'bun:test';
2
+ async function load(name, names, deps = {}) {
3
+ const source = (await Bun.file(new URL('../ui/js/' + name + '.js', import.meta.url)).text())
4
+ .replace(/^\.(?:pragma|import).*$/gm, '');
5
+ return new Function(...Object.keys(deps), source + '; return {' + names + '};')(...Object.values(deps));
6
+ }
7
+ const Scroll = await load('Scroll', 'limitsY,stopTail,tailState');
8
+ const keys = await load('PreviewKeys', 'act,pdfAction,columnPdfAction,pageDirection,pageViewport', { Scroll });
9
+ test('page actions scroll Quick Look without changing the selected file', () => {
10
+ const calls = [];
11
+ const root = { preview: { revealStrip() {}, pageBy: n => calls.push(n) } };
12
+ for (const action of ['pageDown', 'pageUp', 'previewPageForward', 'previewPageBack']) keys.act(action, root);
13
+ expect(calls).toEqual([1, -1, 1, -1]);
14
+ });
15
+ test('both PDF key routes handle every page alias', () => {
16
+ for (const handler of [keys.pdfAction, keys.columnPdfAction]) {
17
+ const calls = [];
18
+ const viewer = { pageBy: n => calls.push(n) };
19
+ for (const action of ['pageDown', 'pageUp', 'previewPageForward', 'previewPageBack']) handler(action, viewer);
20
+ expect(calls).toEqual([1, -1, 1, -1]);
21
+ }
22
+ });
23
+ test('viewport paging respects origin, margins, bounds, and stops wheel momentum', () => {
24
+ let cancelled = 0, layouts = 0;
25
+ const view = { originY: 100, topMargin: 10, bottomMargin: 20, height: 200,
26
+ contentHeight: 700, contentY: 90, cancelFlick() { cancelled++; }, forceLayout() { layouts++; } };
27
+ const tail = Scroll.tailState(view, true); tail.active = true; tail.vy = 2;
28
+ keys.pageViewport(view, 1); expect(view.contentY).toBe(290);
29
+ keys.pageViewport(view, 1); keys.pageViewport(view, 1); expect(view.contentY).toBe(620);
30
+ for (let i = 0; i < 5; i++) keys.pageViewport(view, -1);
31
+ expect(view.contentY).toBe(90);
32
+ expect(tail.active).toBe(false); expect(tail.vy).toBe(0);
33
+ expect(cancelled).toBe(8); expect(layouts).toBe(8);
34
+ view.contentHeight = 20; keys.pageViewport(view, 1); expect(view.contentY).toBe(90);
35
+ keys.pageViewport(null, 1);
36
+ });
37
+ test('Ctrl-Tab returns from Quick Look like Escape', () => {
38
+ let closed = 0;
39
+ keys.act('focusPreview', { preview: { revealStrip() {}, close() { closed++; } } });
40
+ expect(closed).toBe(1);
41
+ });
42
+ test('HTML Quick Look arrows follow adjacent files, including media, rather than scrolling HTML', async () => {
43
+ const followed = [], moved = [];
44
+ const rows = [{n:'1.html',i:1,s:110,k:0},{n:'1.mp3',i:2,s:200,k:1}];
45
+ const root = {cursorIndex:0,kindNames:['HTML','Audio'],join:(base,name)=>base+'/'+name,
46
+ rowFor:i=>rows[i],preview:{isHtml:true,isCompare:false,
47
+ markdownShown:{shownView:'rendered',scrollBy(){throw Error('Arrow must change file');}},
48
+ revealStrip(){},follow:(...args)=>followed.push(args)}};
49
+ const actions=await load('PreviewKeys','act',{
50
+ Filter:{moveCursor(r,n){r.cursorIndex+=n;moved.push(n);},cursorShown:()=>true},
51
+ Marks:{follow(){}},Search:{baseOf:()=>'/test'},Scroll,
52
+ });
53
+ actions.act('cursorDown',root);
54
+ expect(followed[0].slice(0,4)).toEqual(['/test/1.mp3',2,200,'Audio']);
55
+ actions.act('cursorUp',root);
56
+ expect(followed[1][0]).toBe('/test/1.html');
57
+ root.preview.isCompare=true;
58
+ actions.act('cursorDown',root);
59
+ expect(moved).toEqual([1,-1]);
60
+ });
@@ -0,0 +1,22 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {previewLanguage} from '../src/preview-language.js';
3
+ import {code} from '../src/code.js';
4
+ const hint=(text,path='main.v')=>previewLanguage(path,Buffer.from(text));
5
+ test('V declarations distinguish .v source without stealing Verilog',()=>{
6
+ expect(hint('module main\nfn main() { println("hello") }')).toBe('v');
7
+ expect(hint('pub fn (a App) run() {}')).toBe('v');
8
+ expect(hint('fn map[T](x T) T { return x }')).toBe('v');
9
+ expect(hint('module main;\nendmodule')).toBeNull();
10
+ expect(hint('// fn main() {}\nmodule main;\nendmodule')).toBeNull();
11
+ expect(hint('/* fn main() {} */')).toBeNull();
12
+ expect(hint('"fn main() {}"')).toBeNull();
13
+ expect(hint('module main')).toBeNull();
14
+ expect(hint('fn main() {}','main.txt')).toBeNull();
15
+ });
16
+ test('V and Verilog both retain source text and colour through the real preview launcher',async()=>{
17
+ for(const text of ['module main\nfn main() { println("hello") }\n','module counter(input clk);\nreg value;\nendmodule\n']) {
18
+ const reply=await code({name:'main.v',text});
19
+ expect(reply.state).toBe('styled');
20
+ expect(reply.lines.map(r=>r.filter((_,i)=>i%2).join('')).join('\n')).toBe(text.trimEnd());
21
+ }
22
+ });
@@ -0,0 +1,20 @@
1
+ import {test,expect} from 'bun:test';
2
+ async function load(name,names,deps={}) {
3
+ const source=(await Bun.file(new URL('../ui/js/'+name+'.js',import.meta.url)).text()).replace(/^\.(?:pragma|import).*$/gm,'');
4
+ return new Function(...Object.keys(deps),source+';return {'+names+'};')(...Object.values(deps));
5
+ }
6
+ const Match=await load('Match','rowName');
7
+ const Status=await load('Status','fileName',{Match});
8
+ const rows=[{n:'folder/完整的 very long file name.txt'},{n:'Trash/files/name.2',trash:{name:'Original.txt'}},{n:'other.txt'}];
9
+ function pane(selected=[],cursorIndex=0){return {selection:{indices:()=>selected},cursorIndex,shown:null,rowFor:i=>rows[i]??null};}
10
+ test('status names a single selection, or the current row without marks',()=>{
11
+ expect(Status.fileName(pane())).toBe('完整的 very long file name.txt');
12
+ expect(Status.fileName(pane([1]))).toBe('Original.txt');
13
+ expect(Status.fileName(pane([0,1],2))).toBe('other.txt');
14
+ });
15
+ test('status clears absent, filtered or stale cursor names',()=>{
16
+ expect(Status.fileName(null)).toBe('');
17
+ expect(Status.fileName(pane([],50))).toBe('');
18
+ expect(Status.fileName({...pane(),shown:[]})).toBe('');
19
+ expect(Status.fileName({...pane(),listInFlight:true})).toBe('');
20
+ });
@@ -0,0 +1,187 @@
1
+ import { test, expect, afterAll, beforeAll } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, existsSync, symlinkSync, renameSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Paste's Replace and Merge (docs/protocol.md "transfer", "collisions", "mergecollisions"): the
7
+ // item already there goes to flea's Trash, a folder on a folder folds in, and only the names the
8
+ // questions saw are ever answered.
9
+ const root = mkdtempSync(join(tmpdir(), "flea-replace-"));
10
+ const saved = process.env.XDG_DATA_HOME;
11
+ process.env.XDG_DATA_HOME = join(root, "data");
12
+ const { collisions, mergeCollisions, transfer } = await import("../src/transfer.js");
13
+ afterAll(() => {
14
+ if (saved === undefined) delete process.env.XDG_DATA_HOME;
15
+ else process.env.XDG_DATA_HOME = saved;
16
+ rmSync(root, { recursive: true, force: true });
17
+ });
18
+ const trashFiles = () => { try { return readdirSync(join(root, "data", "Trash", "files")); } catch { return []; } };
19
+
20
+ let serial = 0;
21
+ function place() {
22
+ const at = join(root, String(++serial)), src = join(at, "src"), dest = join(at, "dest");
23
+ mkdirSync(src, { recursive: true });
24
+ mkdirSync(dest, { recursive: true });
25
+ const messages = [];
26
+ return { src, dest, messages, send: (m) => messages.push(m) };
27
+ }
28
+ async function run(p, request) {
29
+ const from = p.messages.length;
30
+ transfer(request, p.send, null, 0);
31
+ for (let i = 0; i < 400; i++) {
32
+ const done = p.messages.slice(from).find((m) => m.t === "transferdone");
33
+ if (done) return { done, items: p.messages.slice(from).filter((m) => m.t === "transferitem") };
34
+ await Bun.sleep(5);
35
+ }
36
+ throw Error("no transferdone: " + JSON.stringify(p.messages));
37
+ }
38
+ const ask = (p, id, paths) => { collisions({ id, paths, dest: p.dest }, p.send, null, 0); return p.messages.at(-1); };
39
+ const askMerge = (p, id, paths) => { mergeCollisions({ id, paths, dest: p.dest }, p.send, null, 0); return p.messages.at(-1); };
40
+
41
+ test("Replace sends the item already there to the Trash and lands the incoming one", async () => {
42
+ const p = place(), before = trashFiles().length;
43
+ writeFileSync(join(p.src, "a.txt"), "new");
44
+ writeFileSync(join(p.dest, "a.txt"), "old");
45
+ expect(ask(p, 1, [join(p.src, "a.txt")])).toMatchObject({ t: "collisions", total: 1, mergeable: false });
46
+ const { done } = await run(p, { op: "copy", paths: [join(p.src, "a.txt")], dest: p.dest, collide: "replace", collideId: 1 });
47
+ expect(done).toMatchObject({ ok: 1, failed: 0 });
48
+ expect(done.into).toBeUndefined();
49
+ expect(readFileSync(join(p.dest, "a.txt"), "utf8")).toBe("new");
50
+ expect(trashFiles().length).toBe(before + 1);
51
+ });
52
+
53
+ test("Replace covers only what the question saw: a name swapped since is refused", async () => {
54
+ const p = place();
55
+ writeFileSync(join(p.src, "a.txt"), "new");
56
+ writeFileSync(join(p.dest, "a.txt"), "old");
57
+ ask(p, 2, [join(p.src, "a.txt")]);
58
+ renameSync(join(p.dest, "a.txt"), join(p.dest, "gone"));
59
+ writeFileSync(join(p.dest, "a.txt"), "swapped in");
60
+ const { done, items } = await run(p, { op: "copy", paths: [join(p.src, "a.txt")], dest: p.dest, collide: "replace", collideId: 2 });
61
+ expect(done.failed).toBe(1);
62
+ expect(items[0].err).toBe("an item with that name is already there");
63
+ expect(readFileSync(join(p.dest, "a.txt"), "utf8")).toBe("swapped in");
64
+ });
65
+
66
+ test("Replace refuses an item already there that holds the one moving in, and a link to itself", async () => {
67
+ const p = place();
68
+ // Moving dest/box/inner over dest-side "box" would trash its own source.
69
+ mkdirSync(join(p.dest, "box"));
70
+ mkdirSync(join(p.src, "box"));
71
+ writeFileSync(join(p.dest, "box", "box"), "x");
72
+ const nested = join(p.dest, "box", "box");
73
+ const q = ask(p, 3, [nested]);
74
+ // nested lives in dest/box, not dest, so it collides with dest/box itself.
75
+ expect(q.total).toBe(1);
76
+ const first = await run(p, { op: "move", paths: [nested], dest: p.dest, collide: "replace", collideId: 3 });
77
+ expect(first.items[0].err).toBe("the item already there holds the one being moved in, so it was not replaced");
78
+ expect(existsSync(nested)).toBe(true);
79
+ if (process.platform === "win32") return;
80
+ writeFileSync(join(p.dest, "t.txt"), "target");
81
+ symlinkSync(join(p.dest, "t.txt"), join(p.src, "t.txt"));
82
+ ask(p, 4, [join(p.src, "t.txt")]);
83
+ const second = await run(p, { op: "copy", paths: [join(p.src, "t.txt")], dest: p.dest, collide: "replace", collideId: 4 });
84
+ expect(second.items[0].err).toBe("the incoming link points at the item already there, so it was not replaced");
85
+ expect(readFileSync(join(p.dest, "t.txt"), "utf8")).toBe("target");
86
+ });
87
+
88
+ test("a failed replace puts the item it trashed straight back", async () => {
89
+ if (process.platform === "win32" || process.getuid?.() === 0) return;
90
+ const p = place();
91
+ mkdirSync(join(p.src, "d"));
92
+ writeFileSync(join(p.src, "d", "secret"), "s");
93
+ const { chmodSync } = await import("node:fs");
94
+ chmodSync(join(p.src, "d"), 0o000);
95
+ writeFileSync(join(p.dest, "d"), "old file");
96
+ ask(p, 5, [join(p.src, "d")]);
97
+ const { done, items } = await run(p, { op: "copy", paths: [join(p.src, "d")], dest: p.dest, collide: "replace", collideId: 5 });
98
+ chmodSync(join(p.src, "d"), 0o755);
99
+ expect(done.failed).toBe(1);
100
+ expect(items[0].err).not.toContain("still in the Trash");
101
+ expect(readFileSync(join(p.dest, "d"), "utf8")).toBe("old file");
102
+ });
103
+
104
+ function tree(p) {
105
+ mkdirSync(join(p.src, "trip", "day1"), { recursive: true });
106
+ mkdirSync(join(p.dest, "trip", "day1"), { recursive: true });
107
+ writeFileSync(join(p.src, "trip", "new.jpg"), "new");
108
+ writeFileSync(join(p.src, "trip", "day1", "same.jpg"), "incoming");
109
+ writeFileSync(join(p.src, "trip", "day1", "fresh.jpg"), "fresh");
110
+ writeFileSync(join(p.dest, "trip", "day1", "same.jpg"), "already");
111
+ writeFileSync(join(p.dest, "trip", "keep.jpg"), "untouched");
112
+ return [join(p.src, "trip")];
113
+ }
114
+
115
+ test("Merge is offered for a folder on a folder, and its walk lists the names both hold", () => {
116
+ const p = place(), paths = tree(p);
117
+ expect(ask(p, 10, paths)).toMatchObject({ total: 1, mergeable: true, names: [{ n: "trip", d: true }] });
118
+ expect(askMerge(p, 10, paths)).toMatchObject({ t: "mergecollisions", id: 10, total: 1, names: [{ n: "same.jpg", d: false }] });
119
+ });
120
+
121
+ for (const [inner, expected, keptBoth] of [["keep", "already", 1], ["replace", "incoming", 0], ["skip", "already", 0]]) {
122
+ test(`Merge folds the folders together, and "${inner}" answers the names both hold`, async () => {
123
+ const p = place(), paths = tree(p);
124
+ ask(p, 11, paths);
125
+ askMerge(p, 11, paths);
126
+ const { done } = await run(p, { op: "copy", paths, dest: p.dest, collide: "merge", collideId: 11, inner });
127
+ expect(done).toMatchObject({ ok: 1, failed: 0, into: "trip", keptBoth, merged: 2 + keptBoth + (inner === "replace" ? 1 : 0) });
128
+ const day1 = join(p.dest, "trip", "day1");
129
+ expect(readFileSync(join(day1, "same.jpg"), "utf8")).toBe(expected);
130
+ expect(readFileSync(join(day1, "fresh.jpg"), "utf8")).toBe("fresh");
131
+ expect(readFileSync(join(p.dest, "trip", "new.jpg"), "utf8")).toBe("new");
132
+ expect(readFileSync(join(p.dest, "trip", "keep.jpg"), "utf8")).toBe("untouched");
133
+ if (inner === "keep") expect(readFileSync(join(day1, "same copy.jpg"), "utf8")).toBe("incoming");
134
+ expect(existsSync(join(p.src, "trip", "day1", "same.jpg"))).toBe(true);
135
+ });
136
+ }
137
+
138
+ test("a merging move removes the folders it emptied and leaves what it skipped", async () => {
139
+ const p = place(), paths = tree(p);
140
+ ask(p, 12, paths);
141
+ askMerge(p, 12, paths);
142
+ const { done } = await run(p, { op: "move", paths, dest: p.dest, collide: "merge", collideId: 12, inner: "skip" });
143
+ expect(done).toMatchObject({ ok: 1, failed: 0, merged: 2 });
144
+ expect(readdirSync(join(p.src, "trip"))).toEqual(["day1"]);
145
+ expect(readdirSync(join(p.src, "trip", "day1"))).toEqual(["same.jpg"]);
146
+ expect(readFileSync(join(p.dest, "trip", "day1", "fresh.jpg"), "utf8")).toBe("fresh");
147
+ });
148
+
149
+ test("a name that appears inside after the walk is refused whatever the inner answer", async () => {
150
+ const p = place(), paths = tree(p);
151
+ ask(p, 13, paths);
152
+ askMerge(p, 13, paths);
153
+ writeFileSync(join(p.dest, "trip", "new.jpg"), "late");
154
+ const { done, items } = await run(p, { op: "copy", paths, dest: p.dest, collide: "merge", collideId: 13, inner: "replace" });
155
+ expect(done.failed).toBe(1);
156
+ expect(items[0].err).toBe("trip/new.jpg could not be copied: an item with that name is already there".replaceAll("/", join("a", "b")[1]));
157
+ expect(readFileSync(join(p.dest, "trip", "new.jpg"), "utf8")).toBe("late");
158
+ expect(readFileSync(join(p.dest, "trip", "day1", "same.jpg"), "utf8")).toBe("incoming");
159
+ });
160
+
161
+ test("merge without the walk folds nothing that both hold, and a bad inner word is refused", async () => {
162
+ const p = place(), paths = tree(p);
163
+ ask(p, 14, paths);
164
+ // The second question was never asked: the transfer still merges, but same.jpg is refused.
165
+ const { done } = await run(p, { op: "copy", paths, dest: p.dest, collide: "merge", collideId: 14, inner: "replace" });
166
+ expect(done.failed).toBe(1);
167
+ expect(readFileSync(join(p.dest, "trip", "day1", "same.jpg"), "utf8")).toBe("already");
168
+ transfer({ op: "copy", paths, dest: p.dest, collide: "merge", collideId: 14, inner: "merge" }, p.send, null, 0);
169
+ expect(p.messages.at(-1)).toMatchObject({ t: "error", where: "transfer" });
170
+ });
171
+
172
+ test("into names the merging folder only when it was the transfer's one item", async () => {
173
+ const p = place(), paths = tree(p);
174
+ writeFileSync(join(p.src, "a.txt"), "new");
175
+ writeFileSync(join(p.dest, "a.txt"), "old");
176
+ const both = [...paths, join(p.src, "a.txt")];
177
+ ask(p, 15, both);
178
+ askMerge(p, 15, both);
179
+ const { done } = await run(p, { op: "copy", paths: both, dest: p.dest, collide: "merge", collideId: 15, inner: "keep" });
180
+ expect(done.into).toBe("dest");
181
+ const { readFileSync: read } = await import("node:fs");
182
+ const source = read(new URL("../ui/js/Collide.js", import.meta.url), "utf8").replace(/^\.(?:pragma|import).*$/gm, "");
183
+ const { mergeDest } = new Function("Format", "Ops", "Status", "Swap", source + ";return {mergeDest};")({}, {}, {}, {});
184
+ const request = { dest: "/d" };
185
+ expect(mergeDest(request, [{ n: "trip", d: true }], 1)).toBe("/d/trip");
186
+ expect(mergeDest(request, [{ n: "trip", d: true }, { n: "a.txt", d: false }], 2)).toBe("/d");
187
+ });