@hienlh/ppm 0.21.0 → 0.22.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 (606) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CLAUDE.md +29 -4
  3. package/THIRD-PARTY-NOTICES.md +1 -1
  4. package/assets/skills/ppm/SKILL.md +1 -1
  5. package/assets/skills/ppm/references/http-api.md +1 -1
  6. package/bun.lock +16 -1
  7. package/dist/web/assets/accounts-settings-section-BVr9-FZJ.js +1 -0
  8. package/dist/web/assets/{ai-resource-editor-F09pf8Hd.js → ai-resource-editor-CdDpOwjh.js} +1 -1
  9. package/dist/web/assets/ai-settings-section-B1hQyjAd.js +1 -0
  10. package/dist/web/assets/ai-settings-section-X2UOfSaS.js +1 -0
  11. package/dist/web/assets/{angular-html-DkR4iCth.js → angular-html-G66FNlyn.js} +1 -1
  12. package/dist/web/assets/{angular-ts-DcyL4Chx.js → angular-ts-B-PnUMyt.js} +1 -1
  13. package/dist/web/assets/{api-client-bfB7cXRM.js → api-client-C1ZwRL4Y.js} +1 -1
  14. package/dist/web/assets/api-client-pQL53cDQ.js +1 -0
  15. package/dist/web/assets/{api-settings-BcNjZIvG.js → api-settings-_7Q-ULxp.js} +1 -1
  16. package/dist/web/assets/{api-themes-DBfyVUtX.js → api-themes-DRAERo4Q.js} +1 -1
  17. package/dist/web/assets/{apl-BVlrVtjt.js → apl-BmDqqwE8.js} +1 -1
  18. package/dist/web/assets/{appearance-settings-section-whgnq3ss.js → appearance-settings-section-DLoPK0LD.js} +1 -1
  19. package/dist/web/assets/architecture-PBZL5I3N-Dx6nBEKT.js +1 -0
  20. package/dist/web/assets/{astro-MjJlozLj.js → astro-BfwMNfxr.js} +1 -1
  21. package/dist/web/assets/{audio-preview-Bc54GCAV.js → audio-preview-DsYvgKqj.js} +1 -1
  22. package/dist/web/assets/{blade-DnJWi1fz.js → blade-BsFV4pnN.js} +1 -1
  23. package/dist/web/assets/branch-review-tab-G84Ojzae.js +1 -0
  24. package/dist/web/assets/{bsl-DCmJxSIz.js → bsl-Cw9Outng.js} +1 -1
  25. package/dist/web/assets/{button-VW4QGKCn.js → button-B9CTrsEf.js} +1 -1
  26. package/dist/web/assets/{cairo-C8dDenub.js → cairo-DOK5Wqnd.js} +1 -1
  27. package/dist/web/assets/chat-tab-DQJfjm8E.js +15 -0
  28. package/dist/web/assets/{cobol-DPx_nifl.js → cobol-DRaRy4FK.js} +1 -1
  29. package/dist/web/assets/code-editor-D04iUPsZ.js +7 -0
  30. package/dist/web/assets/{codex-daily-guard-CfybHv_c.js → codex-daily-guard-BSccLRGT.js} +1 -1
  31. package/dist/web/assets/{coffee-DvFzqhgN.js → coffee-DvXuBmEJ.js} +1 -1
  32. package/dist/web/assets/{conflict-editor-DenQUOT2.js → conflict-editor-C4ay676e.js} +2 -2
  33. package/dist/web/assets/context-menu-C6pIsf83.js +1 -0
  34. package/dist/web/assets/{cpp-BLJN0umi.js → cpp-zCmm8rKA.js} +1 -1
  35. package/dist/web/assets/{crystal-BXR-9fGw.js → crystal-CdQx6lB6.js} +1 -1
  36. package/dist/web/assets/{csv-preview-CClrJOuR.js → csv-preview-DbwEmG7o.js} +1 -1
  37. package/dist/web/assets/{data-grid-overlay-editor-D4fhcMfT.js → data-grid-overlay-editor-6xUCeLHh.js} +1 -1
  38. package/dist/web/assets/{database-viewer-DItjm8wB.js → database-viewer-S6LsALjX.js} +1 -1
  39. package/dist/web/assets/{dialog-DQTn55Mt.js → dialog-B84_DJQ8.js} +1 -1
  40. package/dist/web/assets/diff-viewer-3oBA7vgQ.js +4 -0
  41. package/dist/web/assets/{docx-preview-DYBkiUaE.js → docx-preview-CXuk-emm.js} +1 -1
  42. package/dist/web/assets/{edge-rWOlCmXT.js → edge-E8QLt7oi.js} +1 -1
  43. package/dist/web/assets/{edit-diff-preview-COtmo54G.js → edit-diff-preview-BGGGecVw.js} +1 -1
  44. package/dist/web/assets/{editor-language-service-DxkZ51E5.js → editor-language-service-DJmaKVY0.js} +1 -1
  45. package/dist/web/assets/{elixir-E7Y82okW.js → elixir-DPb_-72P.js} +1 -1
  46. package/dist/web/assets/{elm-DTPGbc-w.js → elm-BMzJY5b0.js} +1 -1
  47. package/dist/web/assets/{erb-DHqLHrE6.js → erb-AABQNtMK.js} +1 -1
  48. package/dist/web/assets/{erlang-Byg0nNiV.js → erlang-CEIKOkfP.js} +1 -1
  49. package/dist/web/assets/{esm-D_DmOM7n.js → esm-B9RVMpwV.js} +1 -1
  50. package/dist/web/assets/explorer-body-BQ0jyAGv.js +2 -0
  51. package/dist/web/assets/explorer-window-content-jdkWt34J.js +1 -0
  52. package/dist/web/assets/{extension-manager-section-BhwB6nqr.js → extension-manager-section-DG1guILi.js} +1 -1
  53. package/dist/web/assets/{extension-webview-DI4VL2tJ.js → extension-webview-BGSDUbdK.js} +1 -1
  54. package/dist/web/assets/file-icons-BXdvggK2.js +1 -0
  55. package/dist/web/assets/file-icons.generated-BtBErEwb.css +1 -0
  56. package/dist/web/assets/file-store-ojgMXgAw.js +1 -0
  57. package/dist/web/assets/files-settings-section-Gnyd7han.js +1 -0
  58. package/dist/web/assets/{format-file-meta-DOpzJJhS.js → format-file-meta-C77h04cZ.js} +1 -1
  59. package/dist/web/assets/{fortran-fixed-form-CY1n0Xf6.js → fortran-fixed-form-CrXvLHyi.js} +1 -1
  60. package/dist/web/assets/{fsharp-_vEDSCJz.js → fsharp-DCI4qAz_.js} +1 -1
  61. package/dist/web/assets/{gdresource-B6XT0wAC.js → gdresource-Vb2kO1MG.js} +1 -1
  62. package/dist/web/assets/{general-settings-section-BcxwiZ0X.js → general-settings-section-V6Khh15V.js} +1 -1
  63. package/dist/web/assets/{git-commit-Wk9ovq59.js → git-commit-BMI_OWn-.js} +1 -1
  64. package/dist/web/assets/git-log-panel-Bi513qjT.js +1 -0
  65. package/dist/web/assets/{git-rebase-DSarGPvK.js → git-rebase-D-d_2iqZ.js} +1 -1
  66. package/dist/web/assets/{git-repo-store-Dy69YCHN.js → git-repo-store-88Z8v-pF.js} +1 -1
  67. package/dist/web/assets/gitGraph-HDMCJU4V-C6qRruHI.js +1 -0
  68. package/dist/web/assets/{glide-data-grid-BvgAqSUr.js → glide-data-grid-DnQwYSmQ.js} +7 -7
  69. package/dist/web/assets/{glimmer-js-CkYI35_P.js → glimmer-js-BmNNiR0Z.js} +1 -1
  70. package/dist/web/assets/{glimmer-ts-BqmIZcds.js → glimmer-ts-DQE2Ufha.js} +1 -1
  71. package/dist/web/assets/{glsl-Be-yBCF5.js → glsl-BcIDsxRJ.js} +1 -1
  72. package/dist/web/assets/{graphql-a5DrENHY.js → graphql-DGKSLmGH.js} +1 -1
  73. package/dist/web/assets/group-chat-tab-DpczdwyV.js +1 -0
  74. package/dist/web/assets/{hack-BLAg20aD.js → hack-YiC8bEIb.js} +1 -1
  75. package/dist/web/assets/{haml-Dj6bGOQH.js → haml-DjblxAsz.js} +1 -1
  76. package/dist/web/assets/{handlebars-dmEa36Oh.js → handlebars-BQepOXaU.js} +1 -1
  77. package/dist/web/assets/{html-yacZYzSE.js → html-Cayj_2GP.js} +1 -1
  78. package/dist/web/assets/{html-derivative-lLi3-RNT.js → html-derivative-Weq79bL6.js} +1 -1
  79. package/dist/web/assets/{http-1KaxcE4e.js → http-BeLfdGdz.js} +1 -1
  80. package/dist/web/assets/{hurl-ST-dEEZR.js → hurl-D9rypCLK.js} +1 -1
  81. package/dist/web/assets/{hxml-CpDtuq__.js → hxml-clG8mmSH.js} +1 -1
  82. package/dist/web/assets/{image-preview-CJVxL0uH.js → image-preview-CzCc4_Do.js} +1 -1
  83. package/dist/web/assets/image-preview-geometry-9lgeLYB3.js +1 -0
  84. package/dist/web/assets/index-BH99TIE0.css +1 -0
  85. package/dist/web/assets/index-DU68xk06.js +52 -0
  86. package/dist/web/assets/info-3K5VOQVL-BOSpdwlz.js +1 -0
  87. package/dist/web/assets/{jinja-Cjw-evAn.js → jinja-BShBWfs5.js} +1 -1
  88. package/dist/web/assets/{jira-watcher-section-DIC1yIV3.js → jira-watcher-section-f2HUbGDY.js} +1 -1
  89. package/dist/web/assets/{jison-DfATMYoY.js → jison-dN2Nf_Dr.js} +1 -1
  90. package/dist/web/assets/{julia-DDCaxiqT.js → julia-5U0vR-uJ.js} +1 -1
  91. package/dist/web/assets/{just-CJL529u6.js → just-BMppVRy6.js} +1 -1
  92. package/dist/web/assets/keybindings-store-DCRsnKg5.js +1 -0
  93. package/dist/web/assets/{keyboard-shortcuts-section-BW03P3AF.js → keyboard-shortcuts-section-BpvVEw_Y.js} +1 -1
  94. package/dist/web/assets/{latex-Zc4EpLpW.js → latex-DSSOKjis.js} +1 -1
  95. package/dist/web/assets/{liquid-B_6KMNYS.js → liquid-C24dKoyI.js} +1 -1
  96. package/dist/web/assets/{lua-BtS2idK7.js → lua-DNOsnyhn.js} +1 -1
  97. package/dist/web/assets/markdown-renderer-Bcaf9_bA.js +1 -0
  98. package/dist/web/assets/markdown-renderer-DGJp8HC3.js +3 -0
  99. package/dist/web/assets/{marko-Wk6LBnck.js → marko-26MHO_Q9.js} +1 -1
  100. package/dist/web/assets/{mdc-cRaXBrzL.js → mdc-Dhw9mGrC.js} +1 -1
  101. package/dist/web/assets/{media-url-DL8OCGGg.js → media-url-yfqHpPz0.js} +1 -1
  102. package/dist/web/assets/{mobile-bottom-sheet-CoUauvLF.js → mobile-bottom-sheet-C3TniEP-.js} +1 -1
  103. package/dist/web/assets/mobile-explorer-sheet-Bol3XAO1.js +1 -0
  104. package/dist/web/assets/{nextflow-Bos2wvDV.js → nextflow-xH4oJTex.js} +1 -1
  105. package/dist/web/assets/{nginx-DMlOwzdr.js → nginx-CMz4w0M8.js} +1 -1
  106. package/dist/web/assets/{nim-D2dZQA88.js → nim-D8FnIYsx.js} +1 -1
  107. package/dist/web/assets/notification-store-BXt54VIn.js +1 -0
  108. package/dist/web/assets/{notifications-settings-section-DPUROMw-.js → notifications-settings-section-DqlPDfNX.js} +1 -1
  109. package/dist/web/assets/{number-overlay-editor-DZ2i_Bnb.js → number-overlay-editor-CRGwULhR.js} +1 -1
  110. package/dist/web/assets/{overview-panel-R146OIom.js → overview-panel-Di-3aV9p.js} +1 -1
  111. package/dist/web/assets/packet-RMMSAZCW-BGIE1QdU.js +1 -0
  112. package/dist/web/assets/panel-store-CboGJAwh.js +1 -0
  113. package/dist/web/assets/{pdf-preview-BWUPGLp6.js → pdf-preview-CmBI6E7d.js} +1 -1
  114. package/dist/web/assets/{perl-C5-Z6fPn.js → perl-D9iNWYwb.js} +1 -1
  115. package/dist/web/assets/{php-BXNNhkMH.js → php-Bw3Qo0Zv.js} +1 -1
  116. package/dist/web/assets/pie-UPGHQEXC-Dg-QTbXX.js +1 -0
  117. package/dist/web/assets/{postgres-viewer-CjOvaruz.js → postgres-viewer-8ZAqEALv.js} +3 -3
  118. package/dist/web/assets/{ppmbot-settings-section-DrvWh8l8.js → ppmbot-settings-section-DKJUA3Kq.js} +1 -1
  119. package/dist/web/assets/problems-panel-CFcoUx8h.js +1 -0
  120. package/dist/web/assets/{process-table-BRMS1MrX.js → process-table-EKr2dqG6.js} +1 -1
  121. package/dist/web/assets/project-framework-store-B70Q3dNM.js +1 -0
  122. package/dist/web/assets/project-store-B87hLRSk.js +1 -0
  123. package/dist/web/assets/{proxy-settings-section-Bv0Ny-Yy.js → proxy-settings-section-B7qRVl8w.js} +1 -1
  124. package/dist/web/assets/{pug-C1zf1yWy.js → pug-fT5JC6Vm.js} +1 -1
  125. package/dist/web/assets/{qml-OW58jGpD.js → qml-P_wEDB1w.js} +1 -1
  126. package/dist/web/assets/{query-audit-section-BcHJZcVC.js → query-audit-section-By6qyqdB.js} +1 -1
  127. package/dist/web/assets/radar-KQ55EAFF-DZ6IVu2s.js +1 -0
  128. package/dist/web/assets/{razor-DzjSaobM.js → razor-Drrt2_Ph.js} +1 -1
  129. package/dist/web/assets/remote-desktop-clipboard-notice-0WDYsyCA.js +1 -0
  130. package/dist/web/assets/remote-desktop-mobile-sheet-COYDg16H.js +2 -0
  131. package/dist/web/assets/remote-desktop-mobile-view-2DqSuS2U.js +1 -0
  132. package/dist/web/assets/{remote-desktop-readiness-gate-BTzss2wX.js → remote-desktop-readiness-gate-Dm_qMJMH.js} +1 -1
  133. package/dist/web/assets/remote-desktop-window-content-UodhsF7W.js +1 -0
  134. package/dist/web/assets/{rst-RfuihOUW.js → rst-Dr2mW4-h.js} +1 -1
  135. package/dist/web/assets/{ruby-CNO3pli1.js → ruby-C6EpUsTY.js} +1 -1
  136. package/dist/web/assets/{run-in-terminal-Cb_hVU8x.js → run-in-terminal-CdefEe_R.js} +1 -1
  137. package/dist/web/assets/{sas-DZY5BtXx.js → sas-D8ZEeP6v.js} +1 -1
  138. package/dist/web/assets/{schedules-settings-section-DO8xAOVj.js → schedules-settings-section-bG2lYfGv.js} +1 -1
  139. package/dist/web/assets/{scroll-area-LtNV8g5o.js → scroll-area-BzeiOd-B.js} +1 -1
  140. package/dist/web/assets/{scss--4g-REcN.js → scss-ITL7jQ1f.js} +1 -1
  141. package/dist/web/assets/{send-to-chat-Cnpru1M0.js → send-to-chat-F207yH3u.js} +1 -1
  142. package/dist/web/assets/{separator-Cds1yxy8.js → separator-TRMsJUYe.js} +1 -1
  143. package/dist/web/assets/settings-body-C06bFarv.js +2 -0
  144. package/dist/web/assets/settings-store-krC6bc79.js +2 -0
  145. package/dist/web/assets/settings-tab-34RSaASd.js +1 -0
  146. package/dist/web/assets/settings-window-content-Dr_2lmHH.js +1 -0
  147. package/dist/web/assets/{shaderlab-Cu4sBiVn.js → shaderlab-mW-IIlhk.js} +1 -1
  148. package/dist/web/assets/{shellsession-DWzJfEv7.js → shellsession-DhNl5STt.js} +1 -1
  149. package/dist/web/assets/{soy-BnqE-5Ff.js → soy-SdY0Qjtr.js} +1 -1
  150. package/dist/web/assets/{sparql-Ctg__NhZ.js → sparql-ftqZo2i-.js} +1 -1
  151. package/dist/web/assets/{sql-query-editor-D9k19T_M.js → sql-query-editor-BgRH0cHD.js} +1 -1
  152. package/dist/web/assets/{sqlite-viewer-DoV9hnWJ.js → sqlite-viewer-DUxGslpa.js} +1 -1
  153. package/dist/web/assets/{stata-Qk2h66XW.js → stata-Bj7Yjb4b.js} +1 -1
  154. package/dist/web/assets/{surrealql-8QEX7EfH.js → surrealql-CLhYGAfB.js} +1 -1
  155. package/dist/web/assets/{svelte-D_mubC5-.js → svelte-DTAlRHhK.js} +1 -1
  156. package/dist/web/assets/system-monitor-body-IZuLn7Qh.js +2 -0
  157. package/dist/web/assets/system-monitor-tab-DiSA490Z.js +1 -0
  158. package/dist/web/assets/{system-monitor-window-content-qXLcicFM.js → system-monitor-window-content-CEaFISgj.js} +1 -1
  159. package/dist/web/assets/{tab-host-window-content-7fPZNEni.js → tab-host-window-content-D0TN8Wrq.js} +1 -1
  160. package/dist/web/assets/{tab-store-DGJGnbwv.js → tab-store-OJa1GPCq.js} +1 -1
  161. package/dist/web/assets/team-member-sheet-DDPNcCjd.js +2 -0
  162. package/dist/web/assets/{team-member-window-content-BgvJFypM.js → team-member-window-content-DCiW_1-E.js} +1 -1
  163. package/dist/web/assets/{templ-C1jV0fsO.js → templ-BMfhACxb.js} +1 -1
  164. package/dist/web/assets/{terminal-tab-Cs0sGljx.js → terminal-tab-4ypP0NDY.js} +3 -3
  165. package/dist/web/assets/{tex-D_StFXy_.js → tex-BcssM-QA.js} +1 -1
  166. package/dist/web/assets/tool-cards-Dd6E7Q3k.js +9 -0
  167. package/dist/web/assets/treemap-KZPCXAKY-CE30wCXa.js +1 -0
  168. package/dist/web/assets/{ts-tags-B5m47rEm.js → ts-tags-CrR5IYI8.js} +1 -1
  169. package/dist/web/assets/{twig-C_br6GIl.js → twig-DchfUmyI.js} +1 -1
  170. package/dist/web/assets/{use-blob-url-C-SGi_Hb.js → use-blob-url-CPtCcY3i.js} +1 -1
  171. package/dist/web/assets/{use-inline-blame-B4trxGcR.js → use-inline-blame-vrfFHuaR.js} +1 -1
  172. package/dist/web/assets/{use-lsp-CV0vzILO.js → use-lsp-CsuZu49X.js} +1 -1
  173. package/dist/web/assets/{use-monaco-theme-DBqbWjWX.js → use-monaco-theme-BIVYJBIn.js} +1 -1
  174. package/dist/web/assets/{use-open-team-member-DENsjp0s.js → use-open-team-member-C3mrUzIC.js} +1 -1
  175. package/dist/web/assets/{use-websocket-LgXiX1qN.js → use-websocket-BMbufhX7.js} +1 -1
  176. package/dist/web/assets/{vendor-mermaid-Cbig5zA6.js → vendor-mermaid-BitiXJlL.js} +1 -1
  177. package/dist/web/assets/{vendor-ui-BBdkypKW.js → vendor-ui-DmZ-Mb-H.js} +1 -1
  178. package/dist/web/assets/video-preview-Byj8P_jA.js +1 -0
  179. package/dist/web/assets/{vue-DQwCnLdr.js → vue-1EL6Pw1h.js} +1 -1
  180. package/dist/web/assets/{vue-html-T_EoGpPJ.js → vue-html-BPQNwPC6.js} +1 -1
  181. package/dist/web/assets/{vue-vine-DmxLqX50.js → vue-vine-Bb_dsWLt.js} +1 -1
  182. package/dist/web/assets/{window-pip-registry-3l4hoxuD.js → window-pip-registry-yD6esSju.js} +1 -1
  183. package/dist/web/assets/ws-auth-CEYVQ8NH.js +1 -0
  184. package/dist/web/assets/{xml-Bs6Bw8is.js → xml-dxkLNA0K.js} +1 -1
  185. package/dist/web/assets/{xsl-f5ZNhRCc.js → xsl-Bw2adwrl.js} +1 -1
  186. package/dist/web/index.html +27 -26
  187. package/dist/web/sw.js +1 -1
  188. package/docs/architecture/plugins-and-tracing.md +285 -0
  189. package/docs/lessons-learned.md +31 -0
  190. package/docs/system-architecture.md +1 -0
  191. package/package.json +2 -1
  192. package/packages/ext-git-graph/src/compare-diff-base.test.ts +173 -0
  193. package/packages/ext-git-graph/src/compare-view.ts +22 -3
  194. package/packages/ext-git-graph/src/extension.ts +6 -0
  195. package/packages/ext-git-graph/src/types.ts +19 -0
  196. package/packages/ext-git-graph/src/webview-html.test.ts +134 -1
  197. package/packages/ext-git-graph/src/webview-html.ts +277 -27
  198. package/scripts/gen-file-icons.ts +493 -242
  199. package/src/providers/claude-agent-sdk.ts +42 -1
  200. package/src/providers/codex-app-server/codex-history.ts +77 -23
  201. package/src/server/index.ts +42 -7
  202. package/src/server/routes/chat.ts +15 -11
  203. package/src/server/routes/git.ts +83 -3
  204. package/src/server/routes/remote-desktop.ts +7 -2
  205. package/src/server/ws/global.ts +1 -1
  206. package/src/server/ws/remote-desktop.ts +12 -5
  207. package/src/services/binary-content.ts +25 -0
  208. package/src/services/chat-search.service.ts +1 -1
  209. package/src/services/chat.service.ts +9 -0
  210. package/src/services/event-loop-lag.ts +208 -0
  211. package/src/services/file-lines.ts +114 -0
  212. package/src/services/file-watcher/recreated-dir-poller.ts +108 -6
  213. package/src/services/file-watcher/watch-tree.ts +131 -10
  214. package/src/services/file-watcher.service.ts +73 -23
  215. package/src/services/git-branch-diff/branch-diff-parse.ts +151 -0
  216. package/src/services/git-branch-diff/branch-diff.service.ts +79 -0
  217. package/src/services/git-hunks/git-hunks.service.ts +26 -11
  218. package/src/services/git-hunks/unified-diff.ts +15 -1
  219. package/src/services/git.service.ts +74 -24
  220. package/src/services/jsonl-transcript-parser.ts +138 -6
  221. package/src/services/log-rotate.ts +142 -0
  222. package/src/services/media-transcode/ffmpeg-capabilities.ts +72 -2
  223. package/src/services/remote-desktop/remote-desktop-audio.ts +193 -0
  224. package/src/services/remote-desktop/remote-desktop-capture-input.ts +87 -14
  225. package/src/services/remote-desktop/remote-desktop-capture.ts +40 -7
  226. package/src/services/remote-desktop/remote-desktop-clipboard.ts +166 -0
  227. package/src/services/remote-desktop/remote-desktop-displays.ts +77 -1
  228. package/src/services/remote-desktop/remote-desktop-encoder-args.ts +25 -6
  229. package/src/services/remote-desktop/remote-desktop-evdev-key-map.ts +69 -0
  230. package/src/services/remote-desktop/remote-desktop-input-linux.ts +253 -0
  231. package/src/services/remote-desktop/remote-desktop-input-uinput.ts +242 -0
  232. package/src/services/remote-desktop/remote-desktop-input.ts +40 -8
  233. package/src/services/remote-desktop/remote-desktop-linux-session.ts +146 -0
  234. package/src/services/remote-desktop/remote-desktop-ogg-opus.ts +144 -0
  235. package/src/services/remote-desktop/remote-desktop-privacy.ts +163 -0
  236. package/src/services/remote-desktop/remote-desktop-quality.ts +138 -0
  237. package/src/services/remote-desktop/remote-desktop-requirements.ts +158 -5
  238. package/src/services/remote-desktop/remote-desktop-resolution.ts +302 -0
  239. package/src/services/remote-desktop/remote-desktop-session.ts +523 -21
  240. package/src/services/remote-desktop/remote-desktop-x11.ts +250 -0
  241. package/src/services/supervisor.ts +47 -4
  242. package/src/shared/remote-desktop-custom-quality.ts +117 -0
  243. package/src/shared/remote-desktop-quality.ts +127 -0
  244. package/src/types/chat.ts +4 -0
  245. package/src/types/git.ts +52 -0
  246. package/src/web/components/branch-review/branch-review-tab.tsx +360 -0
  247. package/src/web/components/branch-review/branch-review-tree-row.tsx +138 -0
  248. package/src/web/components/chat/attachment-chips.tsx +30 -6
  249. package/src/web/components/chat/chat-tab.tsx +12 -1
  250. package/src/web/components/chat/message-events.tsx +273 -0
  251. package/src/web/components/chat/message-list.tsx +107 -861
  252. package/src/web/components/chat/message-markdown.tsx +34 -0
  253. package/src/web/components/chat/message-media.tsx +174 -0
  254. package/src/web/components/chat/message-user-bubble.tsx +399 -0
  255. package/src/web/components/chat/provider-selector.tsx +30 -22
  256. package/src/web/components/chat/session-history-list.tsx +2 -2
  257. package/src/web/components/chat/session-list-panel.tsx +3 -2
  258. package/src/web/components/editor/binary-diff-view.tsx +212 -0
  259. package/src/web/components/editor/diff-viewer.tsx +76 -3
  260. package/src/web/components/explorer/file-tree.tsx +235 -134
  261. package/src/web/components/explorer/flatten-visible-tree.ts +35 -40
  262. package/src/web/components/explorer/tree-node.tsx +69 -69
  263. package/src/web/components/explorer/use-tree-row-dnd.ts +3 -4
  264. package/src/web/components/git/git-status-panel.tsx +25 -0
  265. package/src/web/components/layout/draggable-tab.tsx +4 -2
  266. package/src/web/components/layout/nav-section-rail.tsx +12 -3
  267. package/src/web/components/layout/status-bar.tsx +10 -6
  268. package/src/web/components/layout/tab-content.tsx +5 -0
  269. package/src/web/components/layout/tab-pool.tsx +1 -0
  270. package/src/web/components/layout/upgrade-button.tsx +5 -1
  271. package/src/web/components/remote-desktop/remote-desktop-clipboard-client.ts +67 -0
  272. package/src/web/components/remote-desktop/remote-desktop-clipboard-notice.tsx +127 -0
  273. package/src/web/components/remote-desktop/remote-desktop-codec-labels.ts +28 -0
  274. package/src/web/components/remote-desktop/remote-desktop-custom-quality-dialog.tsx +144 -0
  275. package/src/web/components/remote-desktop/remote-desktop-mobile-key-bar.tsx +13 -2
  276. package/src/web/components/remote-desktop/remote-desktop-mobile-settings.tsx +232 -0
  277. package/src/web/components/remote-desktop/remote-desktop-mobile-toolbar.tsx +21 -8
  278. package/src/web/components/remote-desktop/remote-desktop-mobile-view.tsx +82 -3
  279. package/src/web/components/remote-desktop/remote-desktop-recording.ts +76 -0
  280. package/src/web/components/remote-desktop/remote-desktop-resolution-list.ts +100 -0
  281. package/src/web/components/remote-desktop/remote-desktop-toolbar.tsx +497 -0
  282. package/src/web/components/remote-desktop/remote-desktop-view-style.ts +145 -0
  283. package/src/web/components/remote-desktop/remote-desktop-window-content.tsx +126 -35
  284. package/src/web/components/remote-desktop/use-h264-canvas-decoder.ts +11 -0
  285. package/src/web/components/remote-desktop/use-opus-audio-player.ts +148 -0
  286. package/src/web/components/remote-desktop/use-remote-desktop-connection.ts +284 -1
  287. package/src/web/components/remote-desktop/use-remote-desktop-readiness.ts +75 -1
  288. package/src/web/components/remote-desktop/use-remote-desktop-recorder.ts +103 -0
  289. package/src/web/components/remote-desktop/use-remote-desktop-virtual-keyboard.ts +19 -2
  290. package/src/web/components/remote-desktop/use-remote-input-capture.ts +39 -3
  291. package/src/web/components/settings/theme-picker.tsx +2 -1
  292. package/src/web/components/ui/tooltip.tsx +15 -2
  293. package/src/web/hooks/use-delayed-flag.ts +35 -0
  294. package/src/web/lib/branch-review-state.ts +175 -0
  295. package/src/web/lib/file-icon-name.ts +62 -0
  296. package/src/web/lib/file-icons.generated.ts +3287 -25
  297. package/src/web/lib/file-icons.tsx +71 -73
  298. package/src/web/lib/flatten-expansions.ts +18 -5
  299. package/src/web/lib/message-ordinals.ts +23 -0
  300. package/src/web/lib/provider-logos.tsx +143 -0
  301. package/src/web/lib/tab-type-icons.ts +11 -0
  302. package/src/web/stores/file-store.ts +73 -35
  303. package/src/web/stores/panel-utils.ts +5 -0
  304. package/src/web/stores/project-framework-store.ts +82 -0
  305. package/src/web/stores/project-store.ts +7 -1
  306. package/src/web/stores/settings-store.ts +128 -0
  307. package/src/web/stores/tab-store.ts +1 -0
  308. package/src/web/stores/window-panel-persistence.ts +1 -0
  309. package/src/web/styles/file-icons.generated.css +3282 -234
  310. package/src/web/styles/globals.css +26 -0
  311. package/dist/web/assets/accounts-settings-section-BuxD9zH3.js +0 -1
  312. package/dist/web/assets/ai-settings-section-Dd9tOUp0.js +0 -1
  313. package/dist/web/assets/ai-settings-section-FrU2X8Fu.js +0 -1
  314. package/dist/web/assets/api-client-BUCo1lms.js +0 -1
  315. package/dist/web/assets/architecture-PBZL5I3N-5K0wEkAn.js +0 -1
  316. package/dist/web/assets/chat-tab-Cr6_KSGO.js +0 -15
  317. package/dist/web/assets/code-editor-DfmlGamv.js +0 -7
  318. package/dist/web/assets/context-menu-DOODKZdF.js +0 -1
  319. package/dist/web/assets/diff-viewer-BpNj7pN9.js +0 -4
  320. package/dist/web/assets/explorer-body-D-5z464e.js +0 -2
  321. package/dist/web/assets/explorer-window-content-7-1wuRul.js +0 -1
  322. package/dist/web/assets/file-icons-EzPL0q-k.js +0 -1
  323. package/dist/web/assets/file-icons.generated-CfaJuvZu.css +0 -1
  324. package/dist/web/assets/file-store-DMABxOkV.js +0 -1
  325. package/dist/web/assets/files-settings-section-CZzABuKz.js +0 -1
  326. package/dist/web/assets/git-log-panel-CRN4JVOx.js +0 -1
  327. package/dist/web/assets/gitGraph-HDMCJU4V-DkVT2MAM.js +0 -1
  328. package/dist/web/assets/group-chat-tab-DFa5mAx5.js +0 -1
  329. package/dist/web/assets/index-D7DbXD3G.css +0 -1
  330. package/dist/web/assets/index-ZtfxZOyb.js +0 -52
  331. package/dist/web/assets/info-3K5VOQVL-BBsmKGzA.js +0 -1
  332. package/dist/web/assets/keybindings-store-tVfhPANS.js +0 -1
  333. package/dist/web/assets/markdown-renderer-CvL1pWeK.js +0 -3
  334. package/dist/web/assets/markdown-renderer-sJNNVhlS.js +0 -1
  335. package/dist/web/assets/mobile-explorer-sheet-CZ0HZ2E_.js +0 -1
  336. package/dist/web/assets/notification-store-BZbsPnW0.js +0 -1
  337. package/dist/web/assets/packet-RMMSAZCW-DwmYPOlx.js +0 -1
  338. package/dist/web/assets/panel-store-BVLBRn6M.js +0 -1
  339. package/dist/web/assets/pie-UPGHQEXC-D0oogIFi.js +0 -1
  340. package/dist/web/assets/problems-panel-B7BWVhdy.js +0 -1
  341. package/dist/web/assets/project-store-BhMbVln3.js +0 -1
  342. package/dist/web/assets/radar-KQ55EAFF-CQIJOQGa.js +0 -1
  343. package/dist/web/assets/remote-desktop-mobile-sheet-ByjaeZKX.js +0 -2
  344. package/dist/web/assets/remote-desktop-mobile-view-D13mtr9F.js +0 -1
  345. package/dist/web/assets/remote-desktop-window-content-CbqTCaC6.js +0 -1
  346. package/dist/web/assets/settings-body-Rnu-FcX-.js +0 -2
  347. package/dist/web/assets/settings-store-DZPgPtEA.js +0 -2
  348. package/dist/web/assets/settings-tab-C6PHxs0Z.js +0 -1
  349. package/dist/web/assets/settings-window-content-CQ4gT2qj.js +0 -1
  350. package/dist/web/assets/system-monitor-body-2vQ1kMxE.js +0 -2
  351. package/dist/web/assets/system-monitor-tab-krAcvwF7.js +0 -1
  352. package/dist/web/assets/team-member-sheet-2FnRmk1a.js +0 -2
  353. package/dist/web/assets/tool-cards-DU_S2o51.js +0 -9
  354. package/dist/web/assets/treemap-KZPCXAKY-BhEtjljr.js +0 -1
  355. package/dist/web/assets/use-remote-desktop-display-choice-BK_f787P.js +0 -1
  356. package/dist/web/assets/video-preview-BDNwumbW.js +0 -1
  357. package/dist/web/assets/ws-auth-DEAJiGCR.js +0 -1
  358. /package/dist/web/assets/{abap-tkptwpsK.js → abap-IN2yINdN.js} +0 -0
  359. /package/dist/web/assets/{actionscript-3-Di9TMGwg.js → actionscript-3-C_t772eR.js} +0 -0
  360. /package/dist/web/assets/{ada-CB9F079D.js → ada-DPme3emm.js} +0 -0
  361. /package/dist/web/assets/{andromeeda-Dcl8jpb7.js → andromeeda-DSV4i4mZ.js} +0 -0
  362. /package/dist/web/assets/{apache-D0Nk0qsH.js → apache-CWr8s8I8.js} +0 -0
  363. /package/dist/web/assets/{apex-mQIoWncD.js → apex-Z20JnDF5.js} +0 -0
  364. /package/dist/web/assets/{applescript-C6ljbcsI.js → applescript-BKNPu3ga.js} +0 -0
  365. /package/dist/web/assets/{apply-theme-BwIMNXyX.js → apply-theme-CbR31K6I.js} +0 -0
  366. /package/dist/web/assets/{ara-WFqRPGPe.js → ara-hWdY3O96.js} +0 -0
  367. /package/dist/web/assets/{asciidoc-bNd8w4nf.js → asciidoc-eynhq496.js} +0 -0
  368. /package/dist/web/assets/{asm-BQ1_F580.js → asm-Bu02Kq4P.js} +0 -0
  369. /package/dist/web/assets/{aurora-x-Ciqs_Dbu.js → aurora-x-BHBGXbIZ.js} +0 -0
  370. /package/dist/web/assets/{awk-GCR-6KZV.js → awk-B4ojezyv.js} +0 -0
  371. /package/dist/web/assets/{ayu-dark-DxJXuj99.js → ayu-dark-D0GQzFi9.js} +0 -0
  372. /package/dist/web/assets/{ayu-light-jvSyJWDV.js → ayu-light-BVYb5Yn8.js} +0 -0
  373. /package/dist/web/assets/{ayu-mirage-Cg3BPO6H.js → ayu-mirage-BGFW6oNb.js} +0 -0
  374. /package/dist/web/assets/{ballerina-BPz4CrX6.js → ballerina-B6Og3IKT.js} +0 -0
  375. /package/dist/web/assets/{bat-CSTs2hIG.js → bat-D8YFILfx.js} +0 -0
  376. /package/dist/web/assets/{beancount-DMateAZX.js → beancount-BjlbBBuX.js} +0 -0
  377. /package/dist/web/assets/{berry-9lXg0GRM.js → berry-Cz1ygeoj.js} +0 -0
  378. /package/dist/web/assets/{bibtex-DIbqmvFg.js → bibtex-CVkJceJh.js} +0 -0
  379. /package/dist/web/assets/{bicep-02WGsoJ8.js → bicep-DrhYI5GX.js} +0 -0
  380. /package/dist/web/assets/{bird2-BY0tbu3k.js → bird2-BV-KJtmc.js} +0 -0
  381. /package/dist/web/assets/{c-jMBX_9Je.js → c-CuLlvxbG.js} +0 -0
  382. /package/dist/web/assets/{c3-B8JVbNjW.js → c3-CI7-1CJ0.js} +0 -0
  383. /package/dist/web/assets/{cadence-DUIf1tvL.js → cadence-DjxhNP3Q.js} +0 -0
  384. /package/dist/web/assets/{can-open-in-ppm-DGEc0s4P.js → can-open-in-ppm-cv5--yHX.js} +0 -0
  385. /package/dist/web/assets/{catppuccin-frappe-FhUCVxex.js → catppuccin-frappe-CEozpJRh.js} +0 -0
  386. /package/dist/web/assets/{catppuccin-latte-CzfrtmgL.js → catppuccin-latte-C4sXQFMc.js} +0 -0
  387. /package/dist/web/assets/{catppuccin-macchiato-6xL0lOtj.js → catppuccin-macchiato-Ci-fDhXy.js} +0 -0
  388. /package/dist/web/assets/{catppuccin-mocha-D39-xS3P.js → catppuccin-mocha-CH7YXw8d.js} +0 -0
  389. /package/dist/web/assets/{clarity-D8TeGvoT.js → clarity-BWrJ7UmT.js} +0 -0
  390. /package/dist/web/assets/{clojure-Dx_acSX7.js → clojure-C94fh2hb.js} +0 -0
  391. /package/dist/web/assets/{cmake-CyeYPq8o.js → cmake-CtvCkMsG.js} +0 -0
  392. /package/dist/web/assets/{codeowners-DrTI0lmI.js → codeowners-CJnubqzH.js} +0 -0
  393. /package/dist/web/assets/{codeql-CPkFxVW-.js → codeql-BOoP5HY2.js} +0 -0
  394. /package/dist/web/assets/{common-lisp-B0k-3LTj.js → common-lisp-B9nlMFJd.js} +0 -0
  395. /package/dist/web/assets/{coq-uygpo7to.js → coq-CgzIQiSH.js} +0 -0
  396. /package/dist/web/assets/{csharp-DKUYGfSp.js → csharp-4xAIx3jw.js} +0 -0
  397. /package/dist/web/assets/{css-DoOUp7v4.js → css-D4YoLYGn.js} +0 -0
  398. /package/dist/web/assets/{csv-B3YE13gi.js → csv-C2v-ZakW.js} +0 -0
  399. /package/dist/web/assets/{csv-parser-DXePqmXe.js → csv-parser-_oDBUkwQ.js} +0 -0
  400. /package/dist/web/assets/{cue-DViO2wvn.js → cue-Bbznh_an.js} +0 -0
  401. /package/dist/web/assets/{cypher-4BMCIW5N.js → cypher-BKhVEARF.js} +0 -0
  402. /package/dist/web/assets/{d-BT_sTHas.js → d-D6IhWopl.js} +0 -0
  403. /package/dist/web/assets/{dark-plus-DVVXu-1N.js → dark-plus-xAWu790O.js} +0 -0
  404. /package/dist/web/assets/{dart-DHz6f6_6.js → dart-Bj6S9O1j.js} +0 -0
  405. /package/dist/web/assets/{data-grid-types-OgUxMLdQ.js → data-grid-types-C5bthITf.js} +0 -0
  406. /package/dist/web/assets/{dax-ChYCjweK.js → dax-CF2zvaqq.js} +0 -0
  407. /package/dist/web/assets/{desktop-D6Lt4p04.js → desktop-CnmyxMKE.js} +0 -0
  408. /package/dist/web/assets/{diff-D8TDe4hP.js → diff-Qi8WuJsI.js} +0 -0
  409. /package/dist/web/assets/{dist-DOTlFQJA.js → dist-Bjseolz4.js} +0 -0
  410. /package/dist/web/assets/{dist-CrBOCtCW.js → dist-Czq7BUMy.js} +0 -0
  411. /package/dist/web/assets/{docker-Bsp0YbPx.js → docker-vLbbNP5d.js} +0 -0
  412. /package/dist/web/assets/{dotenv-B_7giSY_.js → dotenv-DJMvvNzn.js} +0 -0
  413. /package/dist/web/assets/{dracula-TrQL2iB5.js → dracula-CtAh2rhB.js} +0 -0
  414. /package/dist/web/assets/{dracula-soft-n2XOlgh-.js → dracula-soft-CXtuAAf6.js} +0 -0
  415. /package/dist/web/assets/{dream-maker-CdIEdhpT.js → dream-maker-D5YQyK49.js} +0 -0
  416. /package/dist/web/assets/{editor-font-BDrdiGf1.js → editor-font-ClTC9gaD.js} +0 -0
  417. /package/dist/web/assets/{emacs-lisp-CSbixs6u.js → emacs-lisp-CGTjbiqF.js} +0 -0
  418. /package/dist/web/assets/{everforest-dark-BuZahZYp.js → everforest-dark-D59p7vAQ.js} +0 -0
  419. /package/dist/web/assets/{everforest-light-DPH53z5H.js → everforest-light-B3Qtud3V.js} +0 -0
  420. /package/dist/web/assets/{fennel-B1-K_hlJ.js → fennel-BCyHNUwf.js} +0 -0
  421. /package/dist/web/assets/{fish-Cy12Dbua.js → fish-DDDlLV2G.js} +0 -0
  422. /package/dist/web/assets/{fluent-B_82ys8E.js → fluent-By54R9tl.js} +0 -0
  423. /package/dist/web/assets/{format-bytes-D5sBgIqU.js → format-bytes-D1AQyUCt.js} +0 -0
  424. /package/dist/web/assets/{fortran-free-form-BRQdNzZF.js → fortran-free-form-tP8mRym_.js} +0 -0
  425. /package/dist/web/assets/{gdscript-XBDmLUm9.js → gdscript-nHC2z6Te.js} +0 -0
  426. /package/dist/web/assets/{gdshader-DhVfB3u4.js → gdshader-K-cSmPlW.js} +0 -0
  427. /package/dist/web/assets/{genie-X3K29Y49.js → genie-BAcRhvEe.js} +0 -0
  428. /package/dist/web/assets/{gherkin-DKIx-6cX.js → gherkin-CcifxMLb.js} +0 -0
  429. /package/dist/web/assets/{github-dark-Bf7zdp5s.js → github-dark-67KKlMdB.js} +0 -0
  430. /package/dist/web/assets/{github-dark-default-DqsBBV11.js → github-dark-default-tja8pidR.js} +0 -0
  431. /package/dist/web/assets/{github-dark-dimmed-X2gqRz6w.js → github-dark-dimmed-CtjhYQtx.js} +0 -0
  432. /package/dist/web/assets/{github-dark-high-contrast-Z7tvbGX4.js → github-dark-high-contrast-Bf-LPFgn.js} +0 -0
  433. /package/dist/web/assets/{github-light-BKxt-7zZ.js → github-light-CbJxE7n3.js} +0 -0
  434. /package/dist/web/assets/{github-light-default-B0liF4d4.js → github-light-default-DRQmdHxt.js} +0 -0
  435. /package/dist/web/assets/{github-light-high-contrast-F4encJwr.js → github-light-high-contrast-CJkDgTeM.js} +0 -0
  436. /package/dist/web/assets/{gleam-Pz2bHgBB.js → gleam-D4LCl2ri.js} +0 -0
  437. /package/dist/web/assets/{gn-CgmdNsPU.js → gn-COY8zZmC.js} +0 -0
  438. /package/dist/web/assets/{gnuplot-qLNF5yZ9.js → gnuplot-ROk1VitR.js} +0 -0
  439. /package/dist/web/assets/{go-CSDUNhE7.js → go-H30c3p0C.js} +0 -0
  440. /package/dist/web/assets/{groovy-B3NEnOHF.js → groovy-DBLCaelV.js} +0 -0
  441. /package/dist/web/assets/{gruvbox-dark-hard-DcoftiUX.js → gruvbox-dark-hard-DJy4irps.js} +0 -0
  442. /package/dist/web/assets/{gruvbox-dark-medium-D1wVSuD8.js → gruvbox-dark-medium-Dhfw1QTO.js} +0 -0
  443. /package/dist/web/assets/{gruvbox-dark-soft-DWpunymH.js → gruvbox-dark-soft-BncJU-8w.js} +0 -0
  444. /package/dist/web/assets/{gruvbox-light-hard-rHMvyUdS.js → gruvbox-light-hard-Cq12_YvM.js} +0 -0
  445. /package/dist/web/assets/{gruvbox-light-medium-3O2KiXO7.js → gruvbox-light-medium-DqVja5_v.js} +0 -0
  446. /package/dist/web/assets/{gruvbox-light-soft-lNlXJPwH.js → gruvbox-light-soft-IvwhveGF.js} +0 -0
  447. /package/dist/web/assets/{haskell-aaG3aC0h.js → haskell-DA1JV-MC.js} +0 -0
  448. /package/dist/web/assets/{haxe-CPcHhUDd.js → haxe-V9RTRc5E.js} +0 -0
  449. /package/dist/web/assets/{hcl-CMQNlR_4.js → hcl-hr1lyZ10.js} +0 -0
  450. /package/dist/web/assets/{hjson-DeuklPFj.js → hjson-D3vW3i7X.js} +0 -0
  451. /package/dist/web/assets/{hlsl-BI3hlsaM.js → hlsl-BB85gfJT.js} +0 -0
  452. /package/dist/web/assets/{horizon-C7-gOcjs.js → horizon-DU8OOnj8.js} +0 -0
  453. /package/dist/web/assets/{horizon-bright-Cxt9bRW7.js → horizon-bright-DNMUclW6.js} +0 -0
  454. /package/dist/web/assets/{houston-DbF2G7iD.js → houston-DAiZKf6T.js} +0 -0
  455. /package/dist/web/assets/{hy-DRG6Hs2I.js → hy-C6Zv0LMe.js} +0 -0
  456. /package/dist/web/assets/{image-gallery-CObzTisL.js → image-gallery-CGvlv8zR.js} +0 -0
  457. /package/dist/web/assets/{imba-CxKv_2TW.js → imba-Didnp6Y-.js} +0 -0
  458. /package/dist/web/assets/{ini-BiHsyCdO.js → ini-m0T7q4gQ.js} +0 -0
  459. /package/dist/web/assets/{input-D-dR8RI6.js → input-C7Q8FxeL.js} +0 -0
  460. /package/dist/web/assets/{java-D3RZzgDB.js → java-Buy07jGn.js} +0 -0
  461. /package/dist/web/assets/{javascript-4FmSqG-T.js → javascript-Cjq3CbRL.js} +0 -0
  462. /package/dist/web/assets/{json-CjB236_E.js → json-BSVu8IqD.js} +0 -0
  463. /package/dist/web/assets/{json5-bkfO-Rco.js → json5-NN_Vb7e6.js} +0 -0
  464. /package/dist/web/assets/{jsonc-BwaTeLyv.js → jsonc-BdyZhBUw.js} +0 -0
  465. /package/dist/web/assets/{jsonl-CfxNSwGd.js → jsonl-BsDSNvwi.js} +0 -0
  466. /package/dist/web/assets/{jsonnet-CQETCYrp.js → jsonnet-Gm963F-U.js} +0 -0
  467. /package/dist/web/assets/{jssm-D5o3Zzdz.js → jssm-Ds3bKWgJ.js} +0 -0
  468. /package/dist/web/assets/{jsx-BAMILPLo.js → jsx-BT5mW58X.js} +0 -0
  469. /package/dist/web/assets/{kanagawa-dragon-BhGVxu3R.js → kanagawa-dragon-BvNvf7xd.js} +0 -0
  470. /package/dist/web/assets/{kanagawa-lotus-FH4Y59ro.js → kanagawa-lotus-CuSiliSn.js} +0 -0
  471. /package/dist/web/assets/{kanagawa-wave-eilvP9MB.js → kanagawa-wave-C6g-d6jT.js} +0 -0
  472. /package/dist/web/assets/{kdl-BQ-yvXUK.js → kdl-Bx4kZef-.js} +0 -0
  473. /package/dist/web/assets/{kotlin-BuCx0-VT.js → kotlin-qvaVvCcm.js} +0 -0
  474. /package/dist/web/assets/{kusto-DAN_n_Cy.js → kusto-BPLyNCN6.js} +0 -0
  475. /package/dist/web/assets/{laserwave-Cq2BlxrT.js → laserwave-Sf8ZEhWP.js} +0 -0
  476. /package/dist/web/assets/{lean-Dhq7uXKw.js → lean-DGe6uqgk.js} +0 -0
  477. /package/dist/web/assets/{less-CxaMQb8j.js → less-CqM85x-9.js} +0 -0
  478. /package/dist/web/assets/{lib-BVwmtv2i.js → lib-CD6pOS4u.js} +0 -0
  479. /package/dist/web/assets/{libesm-DdtEjhIi.js → libesm-CPh1Av0r.js} +0 -0
  480. /package/dist/web/assets/{light-plus-B4sMAend.js → light-plus-DBtKwhFA.js} +0 -0
  481. /package/dist/web/assets/{llvm-DQ6-B_6Y.js → llvm-Dpbq3gky.js} +0 -0
  482. /package/dist/web/assets/{log-Bb_vqvu7.js → log-qKMcPm9S.js} +0 -0
  483. /package/dist/web/assets/{logo-BzfYv3A_.js → logo-DFo7i5KH.js} +0 -0
  484. /package/dist/web/assets/{luau-BnAeNR77.js → luau-DGlqWFyL.js} +0 -0
  485. /package/dist/web/assets/{make-B6IFuxpC.js → make-CixdvUQe.js} +0 -0
  486. /package/dist/web/assets/{markdown-Z0xi77Fn.js → markdown-CmRnbngE.js} +0 -0
  487. /package/dist/web/assets/{material-theme-1scy1SYn.js → material-theme-WDUbafB-.js} +0 -0
  488. /package/dist/web/assets/{material-theme-darker-G-JykXQP.js → material-theme-darker-u8y2xpOk.js} +0 -0
  489. /package/dist/web/assets/{material-theme-lighter-BtKkCMVq.js → material-theme-lighter-cyQZK3Tg.js} +0 -0
  490. /package/dist/web/assets/{material-theme-ocean-irrDC89L.js → material-theme-ocean-LBAVvw7k.js} +0 -0
  491. /package/dist/web/assets/{material-theme-palenight-Eq5_K4LM.js → material-theme-palenight-BJqhBxL0.js} +0 -0
  492. /package/dist/web/assets/{matlab-BE3oCXZC.js → matlab-B38sOJee.js} +0 -0
  493. /package/dist/web/assets/{mdx-DYiuFTGw.js → mdx-C8QrE_IW.js} +0 -0
  494. /package/dist/web/assets/{mermaid-DVlaEvtw.js → mermaid-BQ-eeU8A.js} +0 -0
  495. /package/dist/web/assets/{min-dark-DSkeTpQa.js → min-dark-D4gNa5tv.js} +0 -0
  496. /package/dist/web/assets/{min-light-CFv2Q0uF.js → min-light-Dfbhn_PL.js} +0 -0
  497. /package/dist/web/assets/{mipsasm-CiAFuZQ2.js → mipsasm-bLOhJkdm.js} +0 -0
  498. /package/dist/web/assets/{mojo-ARshcaSQ.js → mojo-B0BCOMQW.js} +0 -0
  499. /package/dist/web/assets/{monaco-dotenv-language-BDYnKcnr.js → monaco-dotenv-language-uB-wok6C.js} +0 -0
  500. /package/dist/web/assets/{monokai-CPTV-SOg.js → monokai-Bdw_07OM.js} +0 -0
  501. /package/dist/web/assets/{moonbit-D57B0dZr.js → moonbit-BFdGaeuk.js} +0 -0
  502. /package/dist/web/assets/{move-qgy3G_o9.js → move-CyXQFGPM.js} +0 -0
  503. /package/dist/web/assets/{narrat-CZ3rC-oc.js → narrat-9DvTD3_3.js} +0 -0
  504. /package/dist/web/assets/{nextflow-groovy-BxxIOcI4.js → nextflow-groovy-C4VY3kNY.js} +0 -0
  505. /package/dist/web/assets/{night-owl-B1ryGCda.js → night-owl-feYm66V0.js} +0 -0
  506. /package/dist/web/assets/{night-owl-light-BZvNnvMF.js → night-owl-light-DALqFjvx.js} +0 -0
  507. /package/dist/web/assets/{nix-BVBQi8yv.js → nix-C1kPA9Sw.js} +0 -0
  508. /package/dist/web/assets/{nord-3NXw613h.js → nord-1dvrj1Al.js} +0 -0
  509. /package/dist/web/assets/{nushell-BMOLRalb.js → nushell-DSQ_9Uqj.js} +0 -0
  510. /package/dist/web/assets/{objective-c-AxruEUgh.js → objective-c-Dhcn5ZK8.js} +0 -0
  511. /package/dist/web/assets/{objective-cpp-cFT0qRsD.js → objective-cpp-CGVYCSrp.js} +0 -0
  512. /package/dist/web/assets/{ocaml-_8JH6O8J.js → ocaml-rxXW21An.js} +0 -0
  513. /package/dist/web/assets/{odin-nwYvR0O6.js → odin-ClSJX2DE.js} +0 -0
  514. /package/dist/web/assets/{one-dark-pro-C1LaYJaH.js → one-dark-pro-3gbWDfFW.js} +0 -0
  515. /package/dist/web/assets/{one-light-B7rMc__c.js → one-light-C2q5ZZAw.js} +0 -0
  516. /package/dist/web/assets/{openscad-Cwes8bKO.js → openscad-eHXeafBl.js} +0 -0
  517. /package/dist/web/assets/{pascal-DMt-6tdZ.js → pascal-kKha3Txs.js} +0 -0
  518. /package/dist/web/assets/{pkl-QBTyYQmB.js → pkl-CTiPytg-.js} +0 -0
  519. /package/dist/web/assets/{plastic-BTSUp8Fk.js → plastic-D1Ygezi4.js} +0 -0
  520. /package/dist/web/assets/{plsql-DQv3IFXP.js → plsql-D_gbgZIr.js} +0 -0
  521. /package/dist/web/assets/{po-CNwZx4tW.js → po-BkhbDUnq.js} +0 -0
  522. /package/dist/web/assets/{poimandres-8Os7Qequ.js → poimandres-Do6NywrA.js} +0 -0
  523. /package/dist/web/assets/{polar-b4yK8oD8.js → polar-B3B23wca.js} +0 -0
  524. /package/dist/web/assets/{postcss-sFctwpxx.js → postcss-Dv_YBvWd.js} +0 -0
  525. /package/dist/web/assets/{powerquery-Cg_WrtuN.js → powerquery-fKJWI-Sh.js} +0 -0
  526. /package/dist/web/assets/{powershell-C7Ifz95h.js → powershell-B4Y7KIgx.js} +0 -0
  527. /package/dist/web/assets/{prisma-Z52HUJzQ.js → prisma-J--XdTXX.js} +0 -0
  528. /package/dist/web/assets/{prolog-DLqW-zR1.js → prolog-BBTg4Vb3.js} +0 -0
  529. /package/dist/web/assets/{proto-BOIzxCCJ.js → proto-DWlum3T8.js} +0 -0
  530. /package/dist/web/assets/{puppet-De7TjZIf.js → puppet-ZiUfc9JV.js} +0 -0
  531. /package/dist/web/assets/{purescript-R76ou1WT.js → purescript-CmT8SCHB.js} +0 -0
  532. /package/dist/web/assets/{python-Dprm0tNP.js → python-BURpj-fq.js} +0 -0
  533. /package/dist/web/assets/{qmldir-Clp_jpNw.js → qmldir-CpaiJywQ.js} +0 -0
  534. /package/dist/web/assets/{qss-eQPYYzSo.js → qss-C-zdz0i5.js} +0 -0
  535. /package/dist/web/assets/{r-CCcRHIf5.js → r-K56BeS-s.js} +0 -0
  536. /package/dist/web/assets/{racket-CzE472yp.js → racket-gkn6Z_Jw.js} +0 -0
  537. /package/dist/web/assets/{raku-DQwXrTSi.js → raku-C0ZBuKlx.js} +0 -0
  538. /package/dist/web/assets/{red-KCS6wyop.js → red-BWZ6x-3p.js} +0 -0
  539. /package/dist/web/assets/{reg-CcrAddUh.js → reg-Dt_EsyGR.js} +0 -0
  540. /package/dist/web/assets/{regexp-DDbud-Ri.js → regexp-CT6BLG2C.js} +0 -0
  541. /package/dist/web/assets/{rel-99GDgaHp.js → rel-MJ2LcyzJ.js} +0 -0
  542. /package/dist/web/assets/{resolve-theme-CW2niGV2.js → resolve-theme-B_ukrg_8.js} +0 -0
  543. /package/dist/web/assets/{riscv-Dm2eNv0R.js → riscv-BZYWzMAA.js} +0 -0
  544. /package/dist/web/assets/{ron-CzFt0ygH.js → ron-CLbwHWMn.js} +0 -0
  545. /package/dist/web/assets/{rose-pine-Ck6lCrCP.js → rose-pine-BPiI_daR.js} +0 -0
  546. /package/dist/web/assets/{rose-pine-dawn-vtALRJNj.js → rose-pine-dawn-BjkJc7dW.js} +0 -0
  547. /package/dist/web/assets/{rose-pine-moon-DPto8qXw.js → rose-pine-moon-BQSxkt7g.js} +0 -0
  548. /package/dist/web/assets/{rosmsg-NtS6429H.js → rosmsg-yt4151pJ.js} +0 -0
  549. /package/dist/web/assets/{rust-BjIXvF9h.js → rust-1dAcengL.js} +0 -0
  550. /package/dist/web/assets/{sass-CQ23wf5E.js → sass-DUgtQJsg.js} +0 -0
  551. /package/dist/web/assets/{scala-LfKLM143.js → scala-CxoGYpS0.js} +0 -0
  552. /package/dist/web/assets/{scheme-D3SLBXlF.js → scheme-B2K3u9e3.js} +0 -0
  553. /package/dist/web/assets/{sdbl-ODRd2rGN.js → sdbl-C0Ygk0n6.js} +0 -0
  554. /package/dist/web/assets/{shellscript-3KB1G_oA.js → shellscript-C2CvhqMk.js} +0 -0
  555. /package/dist/web/assets/{slack-dark-DTtyWmIy.js → slack-dark-DYdgzRGj.js} +0 -0
  556. /package/dist/web/assets/{slack-ochin-Bu0PApk3.js → slack-ochin-X-WD0WCg.js} +0 -0
  557. /package/dist/web/assets/{smalltalk-haMT-Sbe.js → smalltalk-Dp7g_zb9.js} +0 -0
  558. /package/dist/web/assets/{snazzy-light-DXJzefE9.js → snazzy-light-DWHWr-a6.js} +0 -0
  559. /package/dist/web/assets/{solarized-dark-B6LyYa18.js → solarized-dark-CxdLsjvb.js} +0 -0
  560. /package/dist/web/assets/{solarized-light-QLADebkv.js → solarized-light-Cz9rAvG5.js} +0 -0
  561. /package/dist/web/assets/{solidity-mzfq1aDc.js → solidity-D33gHHdn.js} +0 -0
  562. /package/dist/web/assets/{splunk-CyH4RWBp.js → splunk-BFbOroYw.js} +0 -0
  563. /package/dist/web/assets/{sql-CWVlT5D3.js → sql-ChpxvmdY.js} +0 -0
  564. /package/dist/web/assets/{ssh-config-DNvQJ1Qx.js → ssh-config-CUuSQ6JG.js} +0 -0
  565. /package/dist/web/assets/{streaming-store-uDu3PoN1.js → streaming-store-BYukdB56.js} +0 -0
  566. /package/dist/web/assets/{stylus-ByQQdtmf.js → stylus-DDihP0_W.js} +0 -0
  567. /package/dist/web/assets/{swift-D1cpY4ow.js → swift-XoalUn4x.js} +0 -0
  568. /package/dist/web/assets/{synthwave-84-BDF_-QiW.js → synthwave-84-DjkG0Bff.js} +0 -0
  569. /package/dist/web/assets/{system-verilog-BYrkMydC.js → system-verilog-f_pLKMYd.js} +0 -0
  570. /package/dist/web/assets/{systemd-CQm3zyK0.js → systemd-DjDcLfxp.js} +0 -0
  571. /package/dist/web/assets/{talonscript-DMW5CzE1.js → talonscript-BpUzYcDl.js} +0 -0
  572. /package/dist/web/assets/{tasl-BhFzMCr2.js → tasl-DP-cUbg8.js} +0 -0
  573. /package/dist/web/assets/{tcl-D7VEAF-9.js → tcl-BHoy-dfE.js} +0 -0
  574. /package/dist/web/assets/{terraform-DdT5A0Vy.js → terraform-vCVxN25B.js} +0 -0
  575. /package/dist/web/assets/{tokyo-night-WGLMM6Q9.js → tokyo-night-D0u4xZba.js} +0 -0
  576. /package/dist/web/assets/{toml-CZ7ipXHZ.js → toml-BS8D0__A.js} +0 -0
  577. /package/dist/web/assets/{tsv-B1zVvoTr.js → tsv-DCb-NQh3.js} +0 -0
  578. /package/dist/web/assets/{tsx-D4HwRUBq.js → tsx-DF8YeRgG.js} +0 -0
  579. /package/dist/web/assets/{turtle-B4r8KEJH.js → turtle-Bhx1GlIy.js} +0 -0
  580. /package/dist/web/assets/{typescript-BnHhxMtp.js → typescript-CAVD2Wb6.js} +0 -0
  581. /package/dist/web/assets/{typespec-DP2q46GT.js → typespec-D7mchdY_.js} +0 -0
  582. /package/dist/web/assets/{typst-DFsD_yaD.js → typst-CeXzq6Yw.js} +0 -0
  583. /package/dist/web/assets/{use-is-mobile-BQ9Bv5Ga.js → use-is-mobile-TkcwP1Gx.js} +0 -0
  584. /package/dist/web/assets/{use-visual-viewport-CbwnktSG.js → use-visual-viewport-Lp878uzs.js} +0 -0
  585. /package/dist/web/assets/{v-Bji8hSVT.js → v-DhzO54je.js} +0 -0
  586. /package/dist/web/assets/{vala-D3TIgGmU.js → vala-Dw_RAk3E.js} +0 -0
  587. /package/dist/web/assets/{vb-DvcxFS2q.js → vb-CiXdNh2Y.js} +0 -0
  588. /package/dist/web/assets/{vendor-xterm-BqPDsE9b.js → vendor-xterm-BHEY6Ctu.js} +0 -0
  589. /package/dist/web/assets/{verilog-Dy7cF35d.js → verilog-fITdxqwV.js} +0 -0
  590. /package/dist/web/assets/{vesper-BzjwUv9J.js → vesper-B1wm_At7.js} +0 -0
  591. /package/dist/web/assets/{vhdl-DuFnFNmW.js → vhdl-ebHjqKp9.js} +0 -0
  592. /package/dist/web/assets/{viml-yAPgKlbz.js → viml-Dgmnoxoo.js} +0 -0
  593. /package/dist/web/assets/{vitesse-black-oSSQdyIZ.js → vitesse-black-BZ5gMb7l.js} +0 -0
  594. /package/dist/web/assets/{vitesse-dark-BZxwPihB.js → vitesse-dark-Lij7kkOt.js} +0 -0
  595. /package/dist/web/assets/{vitesse-light-uIigycvt.js → vitesse-light-DW6Wq7VC.js} +0 -0
  596. /package/dist/web/assets/{vyper-CX3NAxm5.js → vyper-6FxDnVNg.js} +0 -0
  597. /package/dist/web/assets/{wasm-6mEOOvVa.js → wasm-BDxaNPCj.js} +0 -0
  598. /package/dist/web/assets/{wasm-D9td37qO.js → wasm-DKhAtzfe.js} +0 -0
  599. /package/dist/web/assets/{wenyan-lwNIHgk6.js → wenyan-C0k0DYG_.js} +0 -0
  600. /package/dist/web/assets/{wgsl-DQAzCfog.js → wgsl-Y2zagbdn.js} +0 -0
  601. /package/dist/web/assets/{wikitext-DTXmt5NE.js → wikitext-C4QGWzkY.js} +0 -0
  602. /package/dist/web/assets/{wit-Bdw_7WDH.js → wit-CQESzjHy.js} +0 -0
  603. /package/dist/web/assets/{wolfram-DqyHDZ07.js → wolfram-B-ia69Ia.js} +0 -0
  604. /package/dist/web/assets/{yaml-CPXhdByd.js → yaml-CEYYTQPm.js} +0 -0
  605. /package/dist/web/assets/{zenscript-6s_i5zek.js → zenscript-BggWXIeJ.js} +0 -0
  606. /package/dist/web/assets/{zig-B2htezJ_.js → zig-CbaUfBQe.js} +0 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Screenshot and session recording, both taken from the **canvas** rather than from the wire.
3
+ *
4
+ * That is a deliberate trade. Muxing the access units the host already sent would be lossless
5
+ * and free of CPU cost, but it needs an MP4 muxer in the browser and it would record the rung
6
+ * the host chose rather than what the user was looking at — a mid-recording quality change
7
+ * switches resolution and SPS, which no single-track MP4 can express. `captureStream()` +
8
+ * `MediaRecorder` re-encodes (so it costs the *client* CPU) and in exchange records exactly
9
+ * what was on screen, survives every rung change, and carries the audio track in the same file.
10
+ *
11
+ * `captureStream()` is called with no frame rate on purpose: that makes the stream emit a frame
12
+ * whenever the canvas is drawn to, which is the decoder's own cadence. Passing a number
13
+ * resamples to it, so a 30 fps argument against a 60 fps rung would throw half the frames away
14
+ * and a 60 against a 15 fps rung would triple every one of them.
15
+ */
16
+
17
+ /** Container/codec preference. VP9 is the best quality per byte that is universally present in
18
+ * Chromium; H.264-in-MP4 goes first where it exists because it is the one a phone's gallery and
19
+ * every video editor will open without being converted. */
20
+ const MIME_CANDIDATES = [
21
+ "video/mp4;codecs=avc1.42E01E,mp4a.40.2",
22
+ "video/mp4",
23
+ "video/webm;codecs=vp9,opus",
24
+ "video/webm;codecs=vp8,opus",
25
+ "video/webm",
26
+ ];
27
+
28
+ /** The first candidate this browser will actually record, or null when none is supported (no
29
+ * `MediaRecorder` at all — Safari before 14.1, or a locked-down embedded webview). */
30
+ export function pickRecordingMime(
31
+ isSupported: (mime: string) => boolean = (m) =>
32
+ typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(m),
33
+ ): string | null {
34
+ if (typeof MediaRecorder === "undefined") return null;
35
+ return MIME_CANDIDATES.find(isSupported) ?? null;
36
+ }
37
+
38
+ /** `video/webm;codecs=vp9,opus` → `webm`. Taken from the MIME the recorder actually accepted,
39
+ * because a file named `.webm` that holds MP4 bytes is one no player will open. */
40
+ export function extensionForMime(mime: string): string {
41
+ return mime.startsWith("video/mp4") ? "mp4" : "webm";
42
+ }
43
+
44
+ /** `ppm-remote-2026-09-11_14-32-08.mp4` — sortable, and with no `:` so it survives Windows. */
45
+ export function recordingFileName(ext: string, now = new Date()): string {
46
+ const p = (n: number) => String(n).padStart(2, "0");
47
+ const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`
48
+ + `_${p(now.getHours())}-${p(now.getMinutes())}-${p(now.getSeconds())}`;
49
+ return `ppm-remote-${stamp}.${ext}`;
50
+ }
51
+
52
+ /** Hand a blob to the browser as a download. The object URL is revoked on the next task rather
53
+ * than immediately: revoking in the same tick can beat the navigation the click starts, which
54
+ * fails as a "network error" download with nothing in the console. */
55
+ export function downloadBlob(blob: Blob, fileName: string): void {
56
+ const url = URL.createObjectURL(blob);
57
+ const a = document.createElement("a");
58
+ a.href = url;
59
+ a.download = fileName;
60
+ a.rel = "noopener";
61
+ document.body.appendChild(a);
62
+ a.click();
63
+ a.remove();
64
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
65
+ }
66
+
67
+ /** Save the current frame as a PNG. Resolves false when the canvas has no picture yet (a 0×0
68
+ * canvas, i.e. before the first frame) — `toBlob` on one of those yields a blank image rather
69
+ * than failing, which would download a file that looks like a bug in the capture. */
70
+ export async function saveCanvasScreenshot(canvas: HTMLCanvasElement | null): Promise<boolean> {
71
+ if (!canvas || canvas.width === 0 || canvas.height === 0) return false;
72
+ const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
73
+ if (!blob) return false;
74
+ downloadBlob(blob, recordingFileName("png"));
75
+ return true;
76
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Turns the host's raw mode list into the menu RustDesk's `_ResolutionsMenu` shows.
3
+ *
4
+ * The raw list is per *timing*, not per resolution: this dev host advertises 37 modes across 15
5
+ * distinct sizes (seven separate 1920x1080 entries alone). A menu of 37 rows is unusable and
6
+ * says nothing a remote user wants to decide, so they collapse to one row per size.
7
+ *
8
+ * Which one survives the collapse is the part that is easy to get wrong. Keeping the highest
9
+ * refresh looks obviously right — and then a host sitting on 3440x1440@60 while it also
10
+ * advertises @100 has *nothing* ticked in the menu, because the id that is live was dropped.
11
+ * So `current` wins within a group and the refresh only breaks the remaining ties.
12
+ */
13
+ import type { HostMode } from "./use-remote-desktop-readiness";
14
+
15
+ /** One row of the resolution menu. */
16
+ export interface ResolutionChoice {
17
+ /** The mode id to ask the host for. */
18
+ id: string;
19
+ width: number;
20
+ height: number;
21
+ refresh: number;
22
+ current: boolean;
23
+ preferred: boolean;
24
+ }
25
+
26
+ /** One row per distinct size, largest first. */
27
+ export function resolutionChoices(modes: readonly HostMode[]): ResolutionChoice[] {
28
+ const bySize = new Map<string, HostMode>();
29
+ for (const mode of modes) {
30
+ if (mode.width <= 0 || mode.height <= 0) continue;
31
+ const key = `${mode.width}x${mode.height}`;
32
+ const held = bySize.get(key);
33
+ if (!held || beats(mode, held)) bySize.set(key, mode);
34
+ }
35
+ return [...bySize.values()]
36
+ .sort((a, b) => b.width * b.height - a.width * a.height || b.refresh - a.refresh)
37
+ .map((m) => ({
38
+ id: m.id, width: m.width, height: m.height, refresh: m.refresh,
39
+ // `preferred` is kept per *group*: whichever timing survived, the size is still the
40
+ // display's native one and that is what the badge means to a user.
41
+ current: m.current, preferred: m.preferred,
42
+ }));
43
+ }
44
+
45
+ function beats(candidate: HostMode, held: HostMode): boolean {
46
+ if (candidate.current !== held.current) return candidate.current;
47
+ if (candidate.preferred !== held.preferred) return candidate.preferred;
48
+ return candidate.refresh > held.refresh;
49
+ }
50
+
51
+ /** The client's own screen, in the units the host reports its modes in. */
52
+ export interface LocalScreen {
53
+ width: number;
54
+ height: number;
55
+ }
56
+
57
+ /**
58
+ * RustDesk's `_getBestFitResolution` and `_isRemoteResolutionFitLocal` as one decision: the mode
59
+ * to switch to so the host matches this client's screen, or null when the item must not be shown.
60
+ *
61
+ * The match is **exact** — no nearest, no letterboxed approximation. That reads like a missing
62
+ * feature and is the opposite: offering 1920x1080 to a 1366x768 client puts the picture back on
63
+ * the scaler the item exists to avoid, so RustDesk returns null and hides the button instead.
64
+ * On most client/host pairs it is therefore never visible, which is correct.
65
+ */
66
+ export function fitLocalMode(
67
+ modes: readonly HostMode[],
68
+ local: LocalScreen | null,
69
+ currentModeId: string | null,
70
+ ): HostMode | null {
71
+ if (!local || local.width <= 0 || local.height <= 0) return null;
72
+ const group = modes.filter((m) => m.width === local.width && m.height === local.height);
73
+ if (group.length === 0) return null;
74
+ // Compared by *size*, the way RustDesk compares against the display rect rather than a mode id:
75
+ // the host may be on another timing of the size the client already has, and switching between
76
+ // two refresh rates of it would be a menu item that changes nothing the user can see.
77
+ const live = modes.find((m) => m.id === currentModeId) ?? modes.find((m) => m.current) ?? null;
78
+ if (live && live.width === local.width && live.height === local.height) return null;
79
+ // Whichever timing the size's own menu row would offer, so the two cannot disagree.
80
+ return group.reduce((held, m) => (beats(m, held) ? m : held));
81
+ }
82
+
83
+ /**
84
+ * The client's screen in CSS pixels. RustDesk's web branch divides the physical size by
85
+ * `scaleFactor` to get this number; in a browser `screen.width` already is it, and it is the
86
+ * one that makes `scale: original` fill the viewport rather than land at 1 host pixel per
87
+ * *device* pixel — half size on any 2x display.
88
+ */
89
+ export function localScreenSize(): LocalScreen | null {
90
+ if (typeof window === "undefined" || !window.screen) return null;
91
+ const width = Math.round(window.screen.width);
92
+ const height = Math.round(window.screen.height);
93
+ return width > 0 && height > 0 ? { width, height } : null;
94
+ }
95
+
96
+ /** `3440 × 1440`, with the refresh only when there is one to show. */
97
+ export function resolutionLabel(choice: ResolutionChoice): string {
98
+ const size = `${choice.width} × ${choice.height}`;
99
+ return choice.refresh > 0 ? `${size} · ${choice.refresh} Hz` : size;
100
+ }
@@ -0,0 +1,497 @@
1
+ /**
2
+ * The desktop viewer's toolbar, modelled on RustDesk's: icon buttons that open grouped menus,
3
+ * floating over the video rather than taking layout from it.
4
+ *
5
+ * Structure follows RustDesk's `remote_toolbar.dart` — a **Monitor** menu (only meaningful on a
6
+ * multi-display host, so it renders nothing on a single-monitor one, exactly as RustDesk's
7
+ * does) and a **Display** menu holding the scale modes, image quality, and the toggles. What is
8
+ * absent is absent because the feature behind it does not exist yet, not because it was
9
+ * forgotten: codec choice (the host probes one H.264 encoder and uses it), host resolution,
10
+ * virtual displays, audio/mute, recording and privacy mode.
11
+ *
12
+ * The stats overlay keeps its own one-tap button instead of moving inside the menu where
13
+ * RustDesk keeps it: it is a thing people flick on and off while diagnosing a stream, and
14
+ * burying a frequently-toggled control two clicks deep to match another product's layout would
15
+ * be a regression dressed as parity.
16
+ */
17
+ import { useEffect, useMemo, useRef, useState } from "react";
18
+ import { Circle, EyeOff, Gauge, Image, Monitor, Minus, Plus, Settings2, Square } from "@/lib/icons";
19
+ import { cn } from "@/lib/utils";
20
+ import {
21
+ DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem,
22
+ DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator,
23
+ DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
24
+ } from "@/components/ui/dropdown-menu";
25
+ import {
26
+ QUALITY_PRESETS, QUALITY_PRESET_ORDER, type QualityChoice, type QualityPresetId,
27
+ } from "../../../shared/remote-desktop-quality";
28
+ import {
29
+ CUSTOM_SCALE_DEBOUNCE_MS, CUSTOM_SCALE_MAX_PERCENT, CUSTOM_SCALE_MIN_PERCENT,
30
+ CUSTOM_SCALE_NUDGE_PERCENT, VIEW_STYLE_LABELS, VIEW_STYLE_ORDER,
31
+ clampCustomScale, clampScalePercent, scalePercentToPos, scalePosToPercent, snapScalePos,
32
+ type ViewStyle,
33
+ } from "./remote-desktop-view-style";
34
+ import { codecLabel } from "./remote-desktop-codec-labels";
35
+ import {
36
+ fitLocalMode, localScreenSize, resolutionChoices, resolutionLabel,
37
+ } from "./remote-desktop-resolution-list";
38
+ import type { HostMode } from "./use-remote-desktop-readiness";
39
+ import type { RemoteDisplay } from "./use-remote-desktop-readiness";
40
+
41
+ export interface RemoteDesktopToolbarProps {
42
+ displays: RemoteDisplay[];
43
+ currentDisplayId: string | undefined;
44
+ onSelectDisplay: (id: string) => void;
45
+
46
+ quality: { preset: QualityPresetId; held: number };
47
+ /** The rung the client has selected, which is what the menu ticks. Distinct from
48
+ * `quality.preset` (the rung the host confirmed) because `custom` is a choice no preset id
49
+ * can represent. */
50
+ qualityChoice: QualityChoice;
51
+ onSetQuality: (choice: QualityChoice) => void;
52
+ /** Opens the custom dialog. Selecting "Custom" must not commit anything by itself — the
53
+ * numbers live in the dialog. */
54
+ onOpenCustomQuality: () => void;
55
+
56
+ /** Every encoder the host can really run, preference order; the first is its own default. */
57
+ codecs: string[];
58
+ /** The encoder the host is running, or null while it is on its own first choice. */
59
+ codec: string | null;
60
+ onSetCodec: (encoder: string | null) => void;
61
+
62
+ /** The host's own display modes. Empty on a host whose resolution cannot be changed. */
63
+ hostModes: HostMode[];
64
+ /** The mode the host reports as live, or null before it has reported. */
65
+ hostModeId: string | null;
66
+ /** The mode the host was on before this session changed it, for the "Original" item. */
67
+ hostOriginalModeId: string | null;
68
+ onSetHostMode: (modeId: string) => void;
69
+ /** Whether input injection is available. RustDesk gates this whole submenu on
70
+ * `ffiModel.keyboard`: resizing someone else's screen is host control, so a view-only
71
+ * session must not be able to do it even though it can read the mode list. */
72
+ canResizeHost: boolean;
73
+ /** Why the last switch failed, or null. Shown inside the submenu rather than as a toast: the
74
+ * X server refuses a mode per-mode, so the message only means anything next to the list. */
75
+ resolutionError: string | null;
76
+
77
+ viewStyle: ViewStyle;
78
+ customScale: number;
79
+ onSetViewStyle: (style: ViewStyle) => void;
80
+ onSetCustomScale: (scale: number) => void;
81
+
82
+ showCursor: boolean;
83
+ onSetShowCursor: (show: boolean) => void;
84
+
85
+ /** Host audio. `audioReason` is why it cannot be turned on (no loopback device on the host);
86
+ * non-null disables the item and explains itself rather than hiding it, because "there is no
87
+ * sound" is otherwise indistinguishable from a bug. */
88
+ audioOn: boolean;
89
+ audioReason: string | null;
90
+ onSetAudioOn: (on: boolean) => void;
91
+
92
+ /** Privacy mode: the host's local keyboard and mouse stop working and its monitor goes dark.
93
+ * `privacyReason` is why it is unavailable *or* why the last engage failed. */
94
+ privacyOn: boolean;
95
+ privacyReason: string | null;
96
+ privacyCanBlank: boolean;
97
+ onSetPrivacyOn: (on: boolean) => void;
98
+
99
+ /** Session recording. `recordingSupported` is false only where `MediaRecorder` is absent. */
100
+ recording: boolean;
101
+ recordingElapsedSec: number;
102
+ recordingSupported: boolean;
103
+ onToggleRecording: () => void;
104
+ onScreenshot: () => void;
105
+
106
+ clipboardEnabled: boolean;
107
+ onSetClipboardEnabled: (enabled: boolean) => void;
108
+
109
+ statsVisible: boolean;
110
+ onToggleStats: () => void;
111
+ }
112
+
113
+ const BUTTON = "flex size-7 shrink-0 items-center justify-center rounded bg-black/40 text-white/70 "
114
+ + "outline-none hover:bg-black/60 hover:text-white data-[state=open]:bg-black/70 data-[state=open]:text-white";
115
+
116
+ export function RemoteDesktopToolbar({
117
+ displays, currentDisplayId, onSelectDisplay,
118
+ quality, qualityChoice, onSetQuality, onOpenCustomQuality,
119
+ codecs, codec, onSetCodec,
120
+ hostModes, hostModeId, hostOriginalModeId, onSetHostMode, canResizeHost, resolutionError,
121
+ viewStyle, customScale, onSetViewStyle, onSetCustomScale,
122
+ showCursor, onSetShowCursor,
123
+ audioOn, audioReason, onSetAudioOn,
124
+ privacyOn, privacyReason, privacyCanBlank, onSetPrivacyOn,
125
+ recording, recordingElapsedSec, recordingSupported, onToggleRecording, onScreenshot,
126
+ clipboardEnabled, onSetClipboardEnabled,
127
+ statsVisible, onToggleStats,
128
+ }: RemoteDesktopToolbarProps) {
129
+ // The server treats "no displayId" as the primary, so the radio group has to resolve the same
130
+ // way or the menu shows nothing selected on a session that never picked explicitly.
131
+ // Memoised: it is a fresh array per call and the menu re-renders on every stats tick.
132
+ const resolutions = useMemo(() => resolutionChoices(hostModes), [hostModes]);
133
+ // RustDesk reads the local screen once and offers the host mode that matches it exactly.
134
+ // `screen` does not change while a session is open, so this is a one-shot read.
135
+ const localScreen = useMemo(() => localScreenSize(), []);
136
+ const fitLocal = useMemo(
137
+ () => fitLocalMode(hostModes, localScreen, hostModeId),
138
+ [hostModes, localScreen, hostModeId],
139
+ );
140
+ // RustDesk's `showOriginalBtn`: only once the session has moved the host off its own mode.
141
+ const showOriginal = Boolean(hostOriginalModeId && hostOriginalModeId !== hostModeId);
142
+
143
+ const activeDisplay = currentDisplayId
144
+ ?? displays.find((d) => d.primary)?.id
145
+ ?? displays[0]?.id
146
+ ?? "";
147
+
148
+ return (
149
+ <div
150
+ className="absolute right-1 top-1 z-40 flex items-center gap-1"
151
+ data-testid="remote-desktop-toolbar"
152
+ >
153
+ {/* Nothing to choose between on a single-display host. */}
154
+ {displays.length > 1 && (
155
+ <DropdownMenu>
156
+ <DropdownMenuTrigger className={BUTTON} aria-label="Display" title="Display">
157
+ <Monitor className="size-3.5" />
158
+ </DropdownMenuTrigger>
159
+ <DropdownMenuContent align="end" className="w-64">
160
+ <DropdownMenuLabel>Display</DropdownMenuLabel>
161
+ <DropdownMenuRadioGroup value={activeDisplay} onValueChange={onSelectDisplay}>
162
+ {displays.map((d) => (
163
+ <DropdownMenuRadioItem key={d.id} value={d.id} data-testid={`remote-desktop-display-${d.id}`}>
164
+ {d.label}{d.primary ? " (main)" : ""} · {d.width}×{d.height}
165
+ </DropdownMenuRadioItem>
166
+ ))}
167
+ </DropdownMenuRadioGroup>
168
+ </DropdownMenuContent>
169
+ </DropdownMenu>
170
+ )}
171
+
172
+ <DropdownMenu>
173
+ <DropdownMenuTrigger
174
+ className={BUTTON}
175
+ aria-label="Display settings"
176
+ title="Display settings"
177
+ data-testid="remote-desktop-settings-trigger"
178
+ >
179
+ <Settings2 className="size-3.5" />
180
+ </DropdownMenuTrigger>
181
+ <DropdownMenuContent align="end" className="w-64">
182
+ <DropdownMenuLabel>Scale</DropdownMenuLabel>
183
+ <DropdownMenuRadioGroup
184
+ value={viewStyle}
185
+ onValueChange={(v) => onSetViewStyle(v as ViewStyle)}
186
+ >
187
+ {VIEW_STYLE_ORDER.map((style) => (
188
+ <DropdownMenuRadioItem key={style} value={style} data-testid={`remote-desktop-view-style-${style}`}>
189
+ {VIEW_STYLE_LABELS[style]}
190
+ </DropdownMenuRadioItem>
191
+ ))}
192
+ </DropdownMenuRadioGroup>
193
+ {viewStyle === "custom" && <ZoomRow scale={customScale} onSet={onSetCustomScale} />}
194
+
195
+ <DropdownMenuSeparator />
196
+ <DropdownMenuSub>
197
+ <DropdownMenuSubTrigger data-testid="remote-desktop-quality-submenu">
198
+ Image quality
199
+ {/* The choice is a ceiling, so a congested link streams *under* it. Saying so is
200
+ what stops that reading as the menu having been ignored — the alternative is a
201
+ ticked rung the picture does not match, which is how the old freeze behaved. */}
202
+ {quality.held < 1 && (
203
+ <span className="ml-1.5 text-[10px] opacity-60" data-testid="remote-desktop-quality-held">
204
+ {Math.round(quality.held * 100)}% — link limited
205
+ </span>
206
+ )}
207
+ </DropdownMenuSubTrigger>
208
+ <DropdownMenuSubContent className="w-56">
209
+ <DropdownMenuRadioGroup
210
+ value={qualityChoice}
211
+ onValueChange={(v) => (v === "custom" ? onOpenCustomQuality() : onSetQuality(v as QualityChoice))}
212
+ >
213
+ {/* RustDesk's four, in its order, with nothing else: no `auto` rung, because
214
+ the adaptation always runs beneath whichever of these is chosen. */}
215
+ {QUALITY_PRESET_ORDER.map((id) => (
216
+ <DropdownMenuRadioItem key={id} value={id} data-testid={`remote-desktop-quality-${id}`}>
217
+ {QUALITY_PRESETS[id].label}
218
+ </DropdownMenuRadioItem>
219
+ ))}
220
+ {/* Last, as in RustDesk. It opens the dialog rather than committing, because
221
+ "custom" without its two numbers is not a setting. */}
222
+ <DropdownMenuRadioItem value="custom" data-testid="remote-desktop-quality-custom">
223
+ Custom…
224
+ </DropdownMenuRadioItem>
225
+ </DropdownMenuRadioGroup>
226
+ </DropdownMenuSubContent>
227
+ </DropdownMenuSub>
228
+
229
+ {/* One encoder means no choice to offer — which is also the ffmpeg-missing case, and
230
+ the readiness gate above has already explained that one. */}
231
+ {codecs.length > 1 && (
232
+ <DropdownMenuSub>
233
+ <DropdownMenuSubTrigger data-testid="remote-desktop-codec-submenu">
234
+ Codec
235
+ </DropdownMenuSubTrigger>
236
+ <DropdownMenuSubContent className="w-56">
237
+ <DropdownMenuRadioGroup
238
+ value={codec ?? "auto"}
239
+ onValueChange={(v) => onSetCodec(v === "auto" ? null : v)}
240
+ >
241
+ <DropdownMenuRadioItem value="auto">
242
+ Auto · {codecLabel(codecs[0]!)}
243
+ </DropdownMenuRadioItem>
244
+ {codecs.map((enc) => (
245
+ <DropdownMenuRadioItem key={enc} value={enc} data-testid={`remote-desktop-codec-${enc}`}>
246
+ {codecLabel(enc)}
247
+ </DropdownMenuRadioItem>
248
+ ))}
249
+ </DropdownMenuRadioGroup>
250
+ </DropdownMenuSubContent>
251
+ </DropdownMenuSub>
252
+ )}
253
+
254
+ {/* RustDesk's own name for this is "Resolution" (`_ResolutionsMenu`, right here after
255
+ Image quality and Codec), so that is what it is called — calling it anything else
256
+ invites the reading that it changes *this* window rather than the host's screen,
257
+ which is what the scale modes above do.
258
+ One row per size rather than per timing — see `remote-desktop-resolution-list.ts`.
259
+ Absent entirely when the host cannot change its mode (anything but X11 today):
260
+ the reason is informational and belongs nowhere near a menu the user can click. */}
261
+ {canResizeHost && resolutions.length > 1 && (
262
+ <DropdownMenuSub>
263
+ <DropdownMenuSubTrigger data-testid="remote-desktop-resolution-submenu">
264
+ Resolution
265
+ </DropdownMenuSubTrigger>
266
+ <DropdownMenuSubContent className="max-h-80 w-56 overflow-y-auto">
267
+ {/* RustDesk shows "Original" only once the session has moved the host off it
268
+ (`isOriginalResolutionSet && !isOriginalResolution`) — otherwise it is a
269
+ button that does nothing. */}
270
+ {showOriginal && hostOriginalModeId && (
271
+ <DropdownMenuItem
272
+ onSelect={() => onSetHostMode(hostOriginalModeId)}
273
+ data-testid="remote-desktop-resolution-original"
274
+ >
275
+ Original
276
+ </DropdownMenuItem>
277
+ )}
278
+ {/* Absent unless the host advertises this client's screen size *exactly* and is
279
+ not already on it — see `fitLocalMode`. */}
280
+ {fitLocal && (
281
+ <DropdownMenuItem
282
+ onSelect={() => onSetHostMode(fitLocal.id)}
283
+ data-testid="remote-desktop-resolution-fit-local"
284
+ >
285
+ Fit local {fitLocal.width}×{fitLocal.height}
286
+ </DropdownMenuItem>
287
+ )}
288
+ {/* One divider for both, and only when there is something above it —
289
+ RustDesk's `_menuDivider` takes the same two flags. */}
290
+ {(showOriginal || fitLocal) && <DropdownMenuSeparator />}
291
+ <DropdownMenuRadioGroup
292
+ value={hostModeId ?? resolutions.find((r) => r.current)?.id ?? ""}
293
+ onValueChange={onSetHostMode}
294
+ >
295
+ {resolutions.map((r) => (
296
+ <DropdownMenuRadioItem key={r.id} value={r.id} data-testid={`remote-desktop-resolution-${r.width}x${r.height}`}>
297
+ {resolutionLabel(r)}
298
+ {r.preferred && <span className="ml-1.5 text-[10px] opacity-60">native</span>}
299
+ </DropdownMenuRadioItem>
300
+ ))}
301
+ </DropdownMenuRadioGroup>
302
+ {resolutionError && (
303
+ <div className="px-2 py-1.5 text-[10px] leading-tight text-destructive">{resolutionError}</div>
304
+ )}
305
+ </DropdownMenuSubContent>
306
+ </DropdownMenuSub>
307
+ )}
308
+
309
+ <DropdownMenuSeparator />
310
+ {/* Toggling the cursor respawns the capture (~400ms of held picture), because ffmpeg
311
+ takes `-draw_mouse` at startup and there is no way to retune it on a live process. */}
312
+ <DropdownMenuCheckboxItem
313
+ checked={showCursor}
314
+ onCheckedChange={onSetShowCursor}
315
+ data-testid="remote-desktop-show-cursor"
316
+ >
317
+ Show remote cursor
318
+ </DropdownMenuCheckboxItem>
319
+ <DropdownMenuCheckboxItem
320
+ checked={clipboardEnabled}
321
+ onCheckedChange={onSetClipboardEnabled}
322
+ data-testid="remote-desktop-clipboard-toggle"
323
+ >
324
+ Sync clipboard
325
+ </DropdownMenuCheckboxItem>
326
+ {/* Starting audio creates an `AudioContext`, which the autoplay policy only lets
327
+ resume inside a user gesture — this click is that gesture, so it must stay a
328
+ menu item and never move into an effect. */}
329
+ <DropdownMenuCheckboxItem
330
+ checked={audioOn}
331
+ disabled={audioReason !== null}
332
+ onCheckedChange={onSetAudioOn}
333
+ data-testid="remote-desktop-audio-toggle"
334
+ >
335
+ <span className="flex min-w-0 flex-col">
336
+ <span>Play host audio</span>
337
+ {audioReason && <span className="text-[10px] leading-tight text-muted-foreground">{audioReason}</span>}
338
+ </span>
339
+ </DropdownMenuCheckboxItem>
340
+
341
+ <DropdownMenuSeparator />
342
+ {/* Last in the list and described in full, because this is the one item that takes
343
+ the host away from whoever is sitting at it. */}
344
+ <DropdownMenuCheckboxItem
345
+ checked={privacyOn}
346
+ disabled={privacyReason !== null && !privacyOn}
347
+ onCheckedChange={onSetPrivacyOn}
348
+ data-testid="remote-desktop-privacy-toggle"
349
+ >
350
+ <span className="flex min-w-0 flex-col">
351
+ <span>Privacy mode</span>
352
+ <span className="text-[10px] leading-tight text-muted-foreground">
353
+ {privacyReason
354
+ ?? (privacyCanBlank
355
+ ? "Blocks the host's keyboard and mouse, and blanks its screen"
356
+ : "Blocks the host's keyboard and mouse (its screen stays on)")}
357
+ </span>
358
+ </span>
359
+ </DropdownMenuCheckboxItem>
360
+
361
+ <DropdownMenuSeparator />
362
+ <DropdownMenuItem onSelect={onScreenshot} data-testid="remote-desktop-screenshot">
363
+ <Image className="size-3.5" /> Save screenshot
364
+ </DropdownMenuItem>
365
+ {recordingSupported && (
366
+ <DropdownMenuItem onSelect={onToggleRecording} data-testid="remote-desktop-record">
367
+ {recording
368
+ ? <><Square className="size-3.5 text-red-500" /> Stop recording · {formatElapsed(recordingElapsedSec)}</>
369
+ : <><Circle className="size-3.5" /> Start recording</>}
370
+ </DropdownMenuItem>
371
+ )}
372
+ </DropdownMenuContent>
373
+ </DropdownMenu>
374
+
375
+ {/* Visible without opening the menu, and for a stronger reason than recording: the person
376
+ at the host cannot use it right now, and the only way out is from here. */}
377
+ {privacyOn && (
378
+ <button
379
+ type="button"
380
+ onClick={() => onSetPrivacyOn(false)}
381
+ title="Privacy mode is on — click to release the host"
382
+ aria-label="Turn privacy mode off"
383
+ className="flex h-7 shrink-0 items-center gap-1 rounded bg-amber-500/85 px-1.5 text-[10px] font-medium text-black hover:bg-amber-400"
384
+ data-testid="remote-desktop-privacy-indicator"
385
+ >
386
+ <EyeOff className="size-3" /> Host locked
387
+ </button>
388
+ )}
389
+ {/* Visible without opening the menu: a session that is quietly recording has to say so. */}
390
+ {recording && (
391
+ <button
392
+ type="button"
393
+ onClick={onToggleRecording}
394
+ title="Stop recording"
395
+ aria-label="Stop recording"
396
+ className="flex h-7 shrink-0 items-center gap-1 rounded bg-red-600/80 px-1.5 text-[10px] font-medium tabular-nums text-white hover:bg-red-600"
397
+ data-testid="remote-desktop-recording-indicator"
398
+ >
399
+ <span className="size-1.5 animate-pulse rounded-full bg-white" />
400
+ {formatElapsed(recordingElapsedSec)}
401
+ </button>
402
+ )}
403
+ <button
404
+ type="button"
405
+ onClick={onToggleStats}
406
+ aria-label="Toggle stats overlay"
407
+ aria-pressed={statsVisible}
408
+ title="Quality monitor"
409
+ className={cn(BUTTON, statsVisible && "bg-primary/70 text-primary-foreground hover:bg-primary/80")}
410
+ >
411
+ <Gauge className="size-3.5" />
412
+ </button>
413
+ </div>
414
+ );
415
+ }
416
+
417
+ /** `m:ss`, or `h:mm:ss` past an hour. */
418
+ function formatElapsed(totalSec: number): string {
419
+ const h = Math.floor(totalSec / 3600);
420
+ const m = Math.floor((totalSec % 3600) / 60);
421
+ const sec = String(totalSec % 60).padStart(2, "0");
422
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
423
+ }
424
+
425
+ /** Zoom stepper, shown only while the custom mode is selected — `e.preventDefault()` on the
426
+ * item keeps the menu open so the zoom can be nudged more than once per opening. */
427
+ /**
428
+ * RustDesk's scale-custom control: `Row([ −, Expanded(slider), + ])` with the percentage painted
429
+ * on the thumb (`_RectValueThumbShape`).
430
+ *
431
+ * Dragging updates the picture immediately but the *pref* is written on a 300ms debounce, the
432
+ * same as RustDesk's `kDebounceCustomScaleDuration` — a drag across the track is hundreds of
433
+ * ticks, and each one would otherwise be a `persistDevicePref` write.
434
+ *
435
+ * The slider's own range is the normalised track position, not the percent: the mapping is
436
+ * piecewise around the pivot, so a percent-valued slider would be linear and put 100% against
437
+ * the left stop. See `remote-desktop-view-style.ts`.
438
+ */
439
+ function ZoomRow({ scale, onSet }: { scale: number; onSet: (n: number) => void }) {
440
+ // Local while dragging, so the thumb tracks the finger without waiting for the debounce.
441
+ const [dragPercent, setDragPercent] = useState<number | null>(null);
442
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
443
+ const committed = clampScalePercent(Math.round(clampCustomScale(scale) * 100));
444
+ const pct = dragPercent ?? committed;
445
+ const pos = scalePercentToPos(pct);
446
+
447
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
448
+
449
+ const commit = (percent: number) => {
450
+ setDragPercent(percent);
451
+ if (timer.current) clearTimeout(timer.current);
452
+ timer.current = setTimeout(() => { setDragPercent(null); onSet(percent / 100); }, CUSTOM_SCALE_DEBOUNCE_MS);
453
+ };
454
+ const nudge = (direction: 1 | -1) => (e: React.MouseEvent) => {
455
+ e.preventDefault();
456
+ commit(clampScalePercent(pct + direction * CUSTOM_SCALE_NUDGE_PERCENT));
457
+ };
458
+ const btn = "flex size-6 shrink-0 items-center justify-center rounded hover:bg-accent disabled:opacity-40";
459
+
460
+ return (
461
+ <div className="px-2 py-1.5" data-testid="remote-desktop-zoom-row">
462
+ <div className="flex items-center gap-1.5">
463
+ <button type="button" className={btn} onClick={nudge(-1)} disabled={pct <= CUSTOM_SCALE_MIN_PERCENT} aria-label="Decrease">
464
+ <Minus className="size-3.5" />
465
+ </button>
466
+ <span className="relative flex-1 py-2.5">
467
+ <input
468
+ type="range"
469
+ min={0}
470
+ max={1}
471
+ // ~1% precision over the whole range, matching RustDesk's `divisions`.
472
+ step={1 / (CUSTOM_SCALE_MAX_PERCENT - CUSTOM_SCALE_MIN_PERCENT)}
473
+ value={pos}
474
+ aria-label="Scale"
475
+ aria-valuetext={`${pct}%`}
476
+ data-testid="remote-desktop-zoom-slider"
477
+ // `e.stopPropagation()`: the dropdown treats keys as typeahead and would close on
478
+ // the arrow keys the slider needs.
479
+ onKeyDown={(e) => e.stopPropagation()}
480
+ onChange={(e) => commit(scalePosToPercent(snapScalePos(Number(e.target.value))))}
481
+ className="h-1 w-full cursor-pointer appearance-none rounded bg-accent accent-primary"
482
+ />
483
+ <span
484
+ className="pointer-events-none absolute top-0 -translate-x-1/2 rounded bg-primary px-1.5 text-[10px] leading-4 tabular-nums text-primary-foreground"
485
+ style={{ left: `${pos * 100}%` }}
486
+ data-testid="remote-desktop-zoom-value"
487
+ >
488
+ {pct}%
489
+ </span>
490
+ </span>
491
+ <button type="button" className={btn} onClick={nudge(1)} disabled={pct >= CUSTOM_SCALE_MAX_PERCENT} aria-label="Increase">
492
+ <Plus className="size-3.5" />
493
+ </button>
494
+ </div>
495
+ </div>
496
+ );
497
+ }