painapple-code 1.0.0__py3-none-any.whl

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 (436) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +18 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +930 -0
  5. painapple_code/cli/__init__.py +204 -0
  6. painapple_code/cli/deploy/__init__.py +15 -0
  7. painapple_code/cli/deploy/claude_seed.py +41 -0
  8. painapple_code/cli/deploy/config.py +260 -0
  9. painapple_code/cli/deploy/container.py +715 -0
  10. painapple_code/cli/deploy/launch.py +163 -0
  11. painapple_code/cli/deploy/runtime.py +223 -0
  12. painapple_code/cli/lifecycle_cmd.py +443 -0
  13. painapple_code/cli/list_cmd.py +572 -0
  14. painapple_code/cli/manage_cmd.py +670 -0
  15. painapple_code/cli/netinfo.py +125 -0
  16. painapple_code/cli/profiles.py +390 -0
  17. painapple_code/cli/serve_args.py +163 -0
  18. painapple_code/cli/serve_config.py +256 -0
  19. painapple_code/cli/setup_cmd.py +664 -0
  20. painapple_code/cli/ui.py +438 -0
  21. painapple_code/cost_analytics.py +879 -0
  22. painapple_code/data/models.defaults.yaml +18 -0
  23. painapple_code/data/models.yaml +18 -0
  24. painapple_code/data/presets.defaults.json +32 -0
  25. painapple_code/data/strings.yaml +2269 -0
  26. painapple_code/helpers.py +406 -0
  27. painapple_code/paths.py +1514 -0
  28. painapple_code/prompt_explorer.py +984 -0
  29. painapple_code/providers/__init__.py +224 -0
  30. painapple_code/providers/base.py +710 -0
  31. painapple_code/providers/claude/__init__.py +74 -0
  32. painapple_code/providers/claude/capabilities.py +218 -0
  33. painapple_code/providers/claude/errors.py +79 -0
  34. painapple_code/providers/claude/launch.py +50 -0
  35. painapple_code/providers/claude/summary.py +89 -0
  36. painapple_code/providers/claude/translate.py +74 -0
  37. painapple_code/providers/claude_sdk/__init__.py +139 -0
  38. painapple_code/providers/claude_sdk/driver.py +412 -0
  39. painapple_code/providers/codex/__init__.py +87 -0
  40. painapple_code/providers/codex/capabilities.py +321 -0
  41. painapple_code/providers/codex/errors.py +70 -0
  42. painapple_code/providers/codex/launch.py +142 -0
  43. painapple_code/providers/codex/summary.py +196 -0
  44. painapple_code/providers/codex/translate.py +204 -0
  45. painapple_code/providers/codex_app_server/__init__.py +100 -0
  46. painapple_code/providers/codex_app_server/capabilities.py +41 -0
  47. painapple_code/providers/codex_app_server/launch.py +202 -0
  48. painapple_code/providers/codex_app_server/summary.py +291 -0
  49. painapple_code/providers/codex_app_server/translate.py +263 -0
  50. painapple_code/providers/codex_app_server/transport.py +238 -0
  51. painapple_code/routes/__init__.py +0 -0
  52. painapple_code/routes/api_agents.py +441 -0
  53. painapple_code/routes/api_app.py +368 -0
  54. painapple_code/routes/api_app_commit_sections.py +127 -0
  55. painapple_code/routes/api_app_config.py +997 -0
  56. painapple_code/routes/api_app_session_prefs.py +217 -0
  57. painapple_code/routes/api_browser.py +690 -0
  58. painapple_code/routes/api_commands.py +374 -0
  59. painapple_code/routes/api_costs.py +128 -0
  60. painapple_code/routes/api_drafts.py +139 -0
  61. painapple_code/routes/api_exec.py +149 -0
  62. painapple_code/routes/api_files.py +542 -0
  63. painapple_code/routes/api_git.py +657 -0
  64. painapple_code/routes/api_logs.py +649 -0
  65. painapple_code/routes/api_plugins.py +275 -0
  66. painapple_code/routes/api_project_config.py +418 -0
  67. painapple_code/routes/api_prompts.py +231 -0
  68. painapple_code/routes/api_providers.py +53 -0
  69. painapple_code/routes/api_search.py +411 -0
  70. painapple_code/routes/api_session_stash.py +234 -0
  71. painapple_code/routes/api_session_welcome.py +151 -0
  72. painapple_code/routes/api_sessions.py +742 -0
  73. painapple_code/routes/api_shadow.py +323 -0
  74. painapple_code/routes/api_shadow_db.py +512 -0
  75. painapple_code/routes/api_shadow_files.py +332 -0
  76. painapple_code/routes/api_shadow_search.py +476 -0
  77. painapple_code/routes/api_skills.py +610 -0
  78. painapple_code/routes/api_tasks.py +147 -0
  79. painapple_code/routes/api_terminal.py +540 -0
  80. painapple_code/routes/api_upload.py +293 -0
  81. painapple_code/routes/api_viewer.py +229 -0
  82. painapple_code/routes/dependencies.py +109 -0
  83. painapple_code/routes/ws_chat.py +837 -0
  84. painapple_code/server.py +2208 -0
  85. painapple_code/server_logging.py +263 -0
  86. painapple_code/services/__init__.py +0 -0
  87. painapple_code/services/agent_session.py +2738 -0
  88. painapple_code/session_store.py +253 -0
  89. painapple_code/session_store_core.py +879 -0
  90. painapple_code/shadow_db.py +830 -0
  91. painapple_code/shadow_db_plans.py +369 -0
  92. painapple_code/shadow_db_queries.py +595 -0
  93. painapple_code/shadow_db_schema.py +676 -0
  94. painapple_code/shadow_git.py +1296 -0
  95. painapple_code/shadow_git_frontmatter.py +314 -0
  96. painapple_code/shadow_git_sections.py +520 -0
  97. painapple_code/shadow_git_summary.py +462 -0
  98. painapple_code/shadow_parser.py +560 -0
  99. painapple_code/static/css/00-variables.css +276 -0
  100. painapple_code/static/css/01-base.css +245 -0
  101. painapple_code/static/css/02-tooltips.css +36 -0
  102. painapple_code/static/css/09-left-rail.css +448 -0
  103. painapple_code/static/css/10-header.css +777 -0
  104. painapple_code/static/css/11-connection-bar.css +120 -0
  105. painapple_code/static/css/20-messages.css +750 -0
  106. painapple_code/static/css/21-tool-blocks.css +2340 -0
  107. painapple_code/static/css/22-edit-diff.css +619 -0
  108. painapple_code/static/css/23-thinking.css +1584 -0
  109. painapple_code/static/css/24-background-tasks.css +359 -0
  110. painapple_code/static/css/25-context-block.css +252 -0
  111. painapple_code/static/css/26-session-setup.css +144 -0
  112. painapple_code/static/css/30-markdown.css +854 -0
  113. painapple_code/static/css/40-input.css +2418 -0
  114. painapple_code/static/css/41-autocomplete.css +127 -0
  115. painapple_code/static/css/42-activity-strip.css +97 -0
  116. painapple_code/static/css/43-file-autocomplete.css +121 -0
  117. painapple_code/static/css/44-snippets-autocomplete.css +224 -0
  118. painapple_code/static/css/45-selection-bar.css +433 -0
  119. painapple_code/static/css/46-keyboard-bar.css +119 -0
  120. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  121. painapple_code/static/css/47-token-profile.css +106 -0
  122. painapple_code/static/css/50-welcome.css +3095 -0
  123. painapple_code/static/css/51-modals.css +301 -0
  124. painapple_code/static/css/52-utilities.css +77 -0
  125. painapple_code/static/css/53-image-upload.css +456 -0
  126. painapple_code/static/css/54-todo-list.css +102 -0
  127. painapple_code/static/css/55-file-upload.css +113 -0
  128. painapple_code/static/css/56-chat-navigator.css +218 -0
  129. painapple_code/static/css/56-permission-card.css +332 -0
  130. painapple_code/static/css/56-plan-approval.css +204 -0
  131. painapple_code/static/css/56-question-form.css +602 -0
  132. painapple_code/static/css/57-swipe-indicator.css +95 -0
  133. painapple_code/static/css/58-session-families.css +627 -0
  134. painapple_code/static/css/59-about.css +196 -0
  135. painapple_code/static/css/61-log-explorer.css +723 -0
  136. painapple_code/static/css/62-json-tree.css +489 -0
  137. painapple_code/static/css/62-skills-widget.css +650 -0
  138. painapple_code/static/css/63-commands-widget.css +30 -0
  139. painapple_code/static/css/63-plugins-widget.css +174 -0
  140. painapple_code/static/css/63-terminal.css +516 -0
  141. painapple_code/static/css/64-search-files-widget.css +448 -0
  142. painapple_code/static/css/65-snippets-widget.css +426 -0
  143. painapple_code/static/css/66-active-sessions.css +979 -0
  144. painapple_code/static/css/66-agents-widget.css +265 -0
  145. painapple_code/static/css/66-file-explorer-widget.css +855 -0
  146. painapple_code/static/css/66-git-panel.css +791 -0
  147. painapple_code/static/css/66-history-explorer.css +1496 -0
  148. painapple_code/static/css/67-diff-viewer.css +602 -0
  149. painapple_code/static/css/67-editor-view.css +748 -0
  150. painapple_code/static/css/67-file-preview-widget.css +1873 -0
  151. painapple_code/static/css/68-browser-widget.css +337 -0
  152. painapple_code/static/css/68-compare-wizard.css +355 -0
  153. painapple_code/static/css/68-csv-preview.css +106 -0
  154. painapple_code/static/css/68-diff-preview.css +103 -0
  155. painapple_code/static/css/68-helpers-install-widget.css +491 -0
  156. painapple_code/static/css/68-image-annotate.css +346 -0
  157. painapple_code/static/css/68-terminal-view.css +98 -0
  158. painapple_code/static/css/68-uploads-widget.css +229 -0
  159. painapple_code/static/css/69-cost-analytics.css +537 -0
  160. painapple_code/static/css/70-config-panel.css +3736 -0
  161. painapple_code/static/css/70-excalidraw.css +136 -0
  162. painapple_code/static/css/70-prompt-explorer.css +890 -0
  163. painapple_code/static/css/71-chart.css +111 -0
  164. painapple_code/static/css/72-discussion-sidebar.css +678 -0
  165. painapple_code/static/css/74-debug-logs.css +656 -0
  166. painapple_code/static/css/80-open-dialog.css +146 -0
  167. painapple_code/static/css/80-quick-switcher.css +383 -0
  168. painapple_code/static/css/80-widget-system.css +1763 -0
  169. painapple_code/static/css/81-quick-actions.css +1020 -0
  170. painapple_code/static/css/82-context-popover.css +1233 -0
  171. painapple_code/static/css/83-context-menu.css +339 -0
  172. painapple_code/static/css/85-lazy-loading.css +102 -0
  173. painapple_code/static/css/86-zen-mode.css +708 -0
  174. painapple_code/static/css/87-grid-switcher.css +346 -0
  175. painapple_code/static/css/login.css +198 -0
  176. painapple_code/static/dist/app.bundle.js +7177 -0
  177. painapple_code/static/feature-triage.html +446 -0
  178. painapple_code/static/icons/apple-touch-icon.png +0 -0
  179. painapple_code/static/icons/favicon-32.png +0 -0
  180. painapple_code/static/icons/favicon.ico +0 -0
  181. painapple_code/static/icons/icon-128.png +0 -0
  182. painapple_code/static/icons/icon-144.png +0 -0
  183. painapple_code/static/icons/icon-152.png +0 -0
  184. painapple_code/static/icons/icon-180.png +0 -0
  185. painapple_code/static/icons/icon-192.png +0 -0
  186. painapple_code/static/icons/icon-384.png +0 -0
  187. painapple_code/static/icons/icon-512.png +0 -0
  188. painapple_code/static/icons/icon-72.png +0 -0
  189. painapple_code/static/icons/icon-96.png +0 -0
  190. painapple_code/static/js/action-delegate.js +187 -0
  191. painapple_code/static/js/activity-strip.js +193 -0
  192. painapple_code/static/js/app/context-menus.js +619 -0
  193. painapple_code/static/js/app/file-preview.js +163 -0
  194. painapple_code/static/js/app/init-ui.js +574 -0
  195. painapple_code/static/js/app/input-events.js +561 -0
  196. painapple_code/static/js/app/input-resize.js +313 -0
  197. painapple_code/static/js/app/message-actions.js +419 -0
  198. painapple_code/static/js/app/panels.js +210 -0
  199. painapple_code/static/js/app/rendering-delegators.js +246 -0
  200. painapple_code/static/js/app/server-loading.js +365 -0
  201. painapple_code/static/js/app/session-ops.js +287 -0
  202. painapple_code/static/js/app/session-switch.js +443 -0
  203. painapple_code/static/js/app/settings-helpers.js +280 -0
  204. painapple_code/static/js/app/startup-welcome.js +290 -0
  205. painapple_code/static/js/app-context.js +157 -0
  206. painapple_code/static/js/app.js +2134 -0
  207. painapple_code/static/js/auth-fetch.js +46 -0
  208. painapple_code/static/js/background-tasks.js +274 -0
  209. painapple_code/static/js/boot.js +73 -0
  210. painapple_code/static/js/caret-position.js +171 -0
  211. painapple_code/static/js/chat-navigator.js +710 -0
  212. painapple_code/static/js/chat-search.js +323 -0
  213. painapple_code/static/js/command-executor.js +176 -0
  214. painapple_code/static/js/command-store.js +497 -0
  215. painapple_code/static/js/components.js +1414 -0
  216. painapple_code/static/js/config.js +218 -0
  217. painapple_code/static/js/context-menu.js +740 -0
  218. painapple_code/static/js/controllers/chat-controller.js +4252 -0
  219. painapple_code/static/js/controllers/dialog-controller.js +217 -0
  220. painapple_code/static/js/controllers/tab-controller.js +1828 -0
  221. painapple_code/static/js/controllers/thinking-controller.js +915 -0
  222. painapple_code/static/js/diff-utils.js +486 -0
  223. painapple_code/static/js/drafts-pill.js +95 -0
  224. painapple_code/static/js/editor-view.js +815 -0
  225. painapple_code/static/js/effort-settings.js +430 -0
  226. painapple_code/static/js/feature-triage.js +811 -0
  227. painapple_code/static/js/file-autocomplete.js +516 -0
  228. painapple_code/static/js/file-tabs.js +123 -0
  229. painapple_code/static/js/gestures.js +448 -0
  230. painapple_code/static/js/grid-switcher.js +334 -0
  231. painapple_code/static/js/input-handler.js +1472 -0
  232. painapple_code/static/js/keyboard-bar.js +495 -0
  233. painapple_code/static/js/layout-switcher.js +109 -0
  234. painapple_code/static/js/linkify-utils.js +200 -0
  235. painapple_code/static/js/login.js +141 -0
  236. painapple_code/static/js/open-dialog.js +879 -0
  237. painapple_code/static/js/orphan-terminals.js +373 -0
  238. painapple_code/static/js/path-utils.js +179 -0
  239. painapple_code/static/js/perf-marks.js +102 -0
  240. painapple_code/static/js/permission-settings.js +475 -0
  241. painapple_code/static/js/pointer-input.js +59 -0
  242. painapple_code/static/js/preview/json-tree-menu.js +226 -0
  243. painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
  244. painapple_code/static/js/preview/json-tree.js +457 -0
  245. painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
  246. painapple_code/static/js/preview/preview-edit.js +367 -0
  247. painapple_code/static/js/preview/preview-events.js +361 -0
  248. painapple_code/static/js/preview/preview-history.js +567 -0
  249. painapple_code/static/js/preview/preview-inline-edit.js +869 -0
  250. painapple_code/static/js/preview/preview-poll.js +108 -0
  251. painapple_code/static/js/preview/preview-render.js +572 -0
  252. painapple_code/static/js/preview/preview-search.js +421 -0
  253. painapple_code/static/js/preview/preview-state.js +256 -0
  254. painapple_code/static/js/preview/preview-utils.js +251 -0
  255. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  256. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  257. painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
  258. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  259. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  260. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  261. painapple_code/static/js/preview-plugins/index.js +34 -0
  262. painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
  263. painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
  264. painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
  265. painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
  266. painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
  267. painapple_code/static/js/project-color-picker.js +110 -0
  268. painapple_code/static/js/project-colors.js +163 -0
  269. painapple_code/static/js/prompt-favorites.js +187 -0
  270. painapple_code/static/js/quick-actions-menu.js +1736 -0
  271. painapple_code/static/js/quick-actions-registry.js +1239 -0
  272. painapple_code/static/js/quick-switcher/controller.js +294 -0
  273. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  274. painapple_code/static/js/quick-switcher/index.js +30 -0
  275. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  276. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  277. painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
  278. painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
  279. painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
  280. painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
  281. painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
  282. painapple_code/static/js/quick-switcher/registry.js +69 -0
  283. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  284. painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
  285. painapple_code/static/js/recent-opens.js +40 -0
  286. painapple_code/static/js/scroll-manager.js +530 -0
  287. painapple_code/static/js/scroll-state-machine.js +359 -0
  288. painapple_code/static/js/selection/action-bar.js +793 -0
  289. painapple_code/static/js/selection/index.js +15 -0
  290. painapple_code/static/js/selection/selection-handler.js +877 -0
  291. painapple_code/static/js/selection/state.js +181 -0
  292. painapple_code/static/js/session/agent-progress.js +202 -0
  293. painapple_code/static/js/session/handle-agent-message.js +481 -0
  294. painapple_code/static/js/session/handle-message.js +550 -0
  295. painapple_code/static/js/session/interactive.js +587 -0
  296. painapple_code/static/js/session/message-store.js +271 -0
  297. painapple_code/static/js/session/messages.js +253 -0
  298. painapple_code/static/js/session/persistence.js +179 -0
  299. painapple_code/static/js/session/restore.js +136 -0
  300. painapple_code/static/js/session/sync.js +333 -0
  301. painapple_code/static/js/session-container-pool.js +371 -0
  302. painapple_code/static/js/session-setup-panel.js +274 -0
  303. painapple_code/static/js/session.js +1047 -0
  304. painapple_code/static/js/shortcut-hints.js +272 -0
  305. painapple_code/static/js/shortcuts.js +1146 -0
  306. painapple_code/static/js/skills-autocomplete.js +262 -0
  307. painapple_code/static/js/snippets-autocomplete.js +738 -0
  308. painapple_code/static/js/stash-refs-view.js +83 -0
  309. painapple_code/static/js/stash-ui.js +768 -0
  310. painapple_code/static/js/stash.js +785 -0
  311. painapple_code/static/js/status-bar.js +1184 -0
  312. painapple_code/static/js/token-profile.js +274 -0
  313. painapple_code/static/js/tool-renderer-blocks.js +1626 -0
  314. painapple_code/static/js/tool-renderer-thinking.js +813 -0
  315. painapple_code/static/js/tool-renderer.js +1500 -0
  316. painapple_code/static/js/tooltips.js +197 -0
  317. painapple_code/static/js/upload-manager.js +1019 -0
  318. painapple_code/static/js/utils.js +521 -0
  319. painapple_code/static/js/welcome/api.js +363 -0
  320. painapple_code/static/js/welcome/cards.js +626 -0
  321. painapple_code/static/js/welcome/context-menu.js +585 -0
  322. painapple_code/static/js/welcome/families.js +801 -0
  323. painapple_code/static/js/welcome/preview.js +234 -0
  324. painapple_code/static/js/welcome/state.js +129 -0
  325. painapple_code/static/js/welcome.js +1874 -0
  326. painapple_code/static/js/widget-system/base-widget.js +717 -0
  327. painapple_code/static/js/widget-system/device-manager.js +178 -0
  328. painapple_code/static/js/widget-system/event-bus.js +152 -0
  329. painapple_code/static/js/widget-system/icons.js +120 -0
  330. painapple_code/static/js/widget-system/index.js +49 -0
  331. painapple_code/static/js/widget-system/init.js +54 -0
  332. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  333. painapple_code/static/js/widget-system/types/floating.js +915 -0
  334. painapple_code/static/js/widget-system/types/index.js +40 -0
  335. painapple_code/static/js/widget-system/types/modal.js +147 -0
  336. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  337. painapple_code/static/js/widget-system/types/tab.js +146 -0
  338. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  339. painapple_code/static/js/widget-system/widget-manager.js +1187 -0
  340. painapple_code/static/js/widgets/about-widget.js +260 -0
  341. painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
  342. painapple_code/static/js/widgets/agents-widget.js +897 -0
  343. painapple_code/static/js/widgets/browser-widget.js +579 -0
  344. painapple_code/static/js/widgets/commands-widget.js +694 -0
  345. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  346. painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
  347. painapple_code/static/js/widgets/config/gestures.js +161 -0
  348. painapple_code/static/js/widgets/config/models-tab.js +988 -0
  349. painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
  350. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  351. painapple_code/static/js/widgets/config/state.js +517 -0
  352. painapple_code/static/js/widgets/config/system-controls.js +90 -0
  353. painapple_code/static/js/widgets/config-widget.js +1535 -0
  354. painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
  355. painapple_code/static/js/widgets/debug-widget.js +693 -0
  356. painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
  357. painapple_code/static/js/widgets/discussion-widget.js +1107 -0
  358. painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
  359. painapple_code/static/js/widgets/file-preview-widget.js +861 -0
  360. painapple_code/static/js/widgets/git-widget.js +794 -0
  361. painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
  362. painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
  363. painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
  364. painapple_code/static/js/widgets/image-preview-widget.js +692 -0
  365. painapple_code/static/js/widgets/index.js +176 -0
  366. painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
  367. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  368. painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
  369. painapple_code/static/js/widgets/search-files-widget.js +529 -0
  370. painapple_code/static/js/widgets/skills-widget.js +843 -0
  371. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  372. painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
  373. painapple_code/static/js/widgets/tasks-widget.js +379 -0
  374. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  375. painapple_code/static/js/widgets/terminal/gestures.js +357 -0
  376. painapple_code/static/js/widgets/terminal/init.js +642 -0
  377. painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
  378. painapple_code/static/js/widgets/terminal/osc52.js +213 -0
  379. painapple_code/static/js/widgets/terminal/render.js +159 -0
  380. painapple_code/static/js/widgets/terminal/size.js +54 -0
  381. painapple_code/static/js/widgets/terminal/state.js +211 -0
  382. painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
  383. painapple_code/static/js/widgets/terminal-widget.js +793 -0
  384. painapple_code/static/js/widgets/uploads-widget.js +461 -0
  385. painapple_code/static/js/widgets/zen-widget.js +1001 -0
  386. painapple_code/static/login.html +51 -0
  387. painapple_code/static/manifest.json +73 -0
  388. painapple_code/static/sw.js +293 -0
  389. painapple_code/static/vendor/LICENSES.txt +769 -0
  390. painapple_code/static/vendor/README.md +103 -0
  391. painapple_code/static/vendor/codemirror.js +35 -0
  392. painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
  393. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  394. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  395. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  396. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  397. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  398. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  399. painapple_code/static/vendor/highlight.min.js +1213 -0
  400. painapple_code/static/vendor/marked.min.js +6 -0
  401. painapple_code/static/vendor/purify.min.js +3 -0
  402. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  403. painapple_code/static/vendor/xterm.css +209 -0
  404. painapple_code/static/vendor/xterm.js +2 -0
  405. painapple_code/static/web-client.html +635 -0
  406. painapple_code/subprocess_registry.py +535 -0
  407. painapple_code/tls_cert.py +79 -0
  408. painapple_code/tools/agents/shadow-git-helper.md +400 -0
  409. painapple_code/tools/excalidraw-to-svg.js +143 -0
  410. painapple_code/tools/install-helpers.sh +148 -0
  411. painapple_code/tools/shadow-git +320 -0
  412. painapple_code/tools/shadow-query +120 -0
  413. painapple_code/tools/vegalite-to-svg.js +79 -0
  414. painapple_code/turn_query.py +766 -0
  415. painapple_code/turn_tracker.py +221 -0
  416. painapple_code/utils/__init__.py +0 -0
  417. painapple_code/utils/agent_cli.py +306 -0
  418. painapple_code/utils/chart.py +103 -0
  419. painapple_code/utils/excalidraw.py +107 -0
  420. painapple_code/utils/file_lock.py +162 -0
  421. painapple_code/utils/file_paths.py +648 -0
  422. painapple_code/utils/generate_icons.py +251 -0
  423. painapple_code/utils/proc.py +205 -0
  424. painapple_code/utils/pty_backend.py +59 -0
  425. painapple_code/utils/pty_posix.py +160 -0
  426. painapple_code/utils/pty_windows.py +245 -0
  427. painapple_code/utils/token_profiles.py +91 -0
  428. painapple_code/viewer_templates.py +657 -0
  429. painapple_code/welcome_search.py +879 -0
  430. painapple_code-1.0.0.dist-info/METADATA +292 -0
  431. painapple_code-1.0.0.dist-info/RECORD +436 -0
  432. painapple_code-1.0.0.dist-info/WHEEL +5 -0
  433. painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
  434. painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
  435. painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
  436. painapple_code-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2166 @@
1
+ /**
2
+ * FileExplorerWidget - File browser
3
+ *
4
+ * Features:
5
+ * - Tree list with expansion + inline filter (recursive via /api/files/list)
6
+ * - Session-aware navigation via breadcrumbs
7
+ * - File type filtering and sort
8
+ * - Keyboard navigation
9
+ * - Context menu (preview, open, copy path, insert to chat, compare)
10
+ *
11
+ * Scope history: tree/bookmarks/recent views + back/forward history +
12
+ * full-screen tab mode were cut 2026-04-19. Separate "search view" mode was
13
+ * cut 2026-04-21 — the inline filter now handles subtree search.
14
+ */
15
+
16
+ import S from '../strings.js';
17
+ import { escapeHtml, formatDate } from '../utils.js';
18
+ import { basename, dirname, isUnder, joinPath, parentOf, pathRoot, pathSep, relativeTo, resolvePath, splitPath, stripTrailingSep } from '../path-utils.js';
19
+ import { CONFIG } from '../config.js';
20
+ import { WidgetManager, WidgetBus, ICONS } from '../widget-system/index.js';
21
+ import { BrowserWidget } from './browser-widget.js';
22
+
23
+ const GIT_STATUS_LABELS = {
24
+ M: S.widgets.git.statuses.modified,
25
+ A: S.widgets.git.statuses.added,
26
+ D: S.widgets.git.statuses.deleted,
27
+ U: S.widgets.git.statuses.untracked,
28
+ S: S.widgets.git.statuses.staged,
29
+ };
30
+
31
+ // Compact SVG glyphs for the per-file git badge. Stroke=current so they pick
32
+ // up the per-status color from .fe-git-{M,A,D,U,S} in CSS.
33
+ const GIT_STATUS_ICONS = {
34
+ // Modified — pencil
35
+ M: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>',
36
+ // Added — plus
37
+ A: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
38
+ // Deleted — minus
39
+ D: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>',
40
+ // Untracked — dotted/open circle (question-mark feel)
41
+ U: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="8" stroke-dasharray="2 3"/></svg>',
42
+ // Staged — check
43
+ S: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
44
+ };
45
+
46
+ // Grouping icons: folder-over-file / file-over-folder / mixed stack
47
+ const GROUP_ICONS = {
48
+ 'dirs-first': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v3H3V7z"/><rect x="5" y="14" width="14" height="6" rx="1"/></svg>',
49
+ 'files-first': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="4" width="14" height="6" rx="1"/><path d="M3 14a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v3H3v-5z"/></svg>',
50
+ 'mixed': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="4" rx="1"/><rect x="4" y="10" width="16" height="4" rx="1"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>',
51
+ };
52
+
53
+ // Types worth rendering in the browser widget (vs. plain-text Preview):
54
+ // HTML gets the full render pipeline; the rest are iframe-native formats
55
+ const BROWSER_RENDERABLE = /\.(html?|svg|pdf|png|jpe?g|gif|webp|ico)$/i;
56
+
57
+ // ============================================================================
58
+ // State Management (Singleton - persists across widget transforms)
59
+ // ============================================================================
60
+
61
+ class FileExplorerState {
62
+ constructor() {
63
+ this.reset();
64
+ }
65
+
66
+ reset() {
67
+ this.sessionId = null;
68
+ this.cwd = null;
69
+ this.currentPath = null;
70
+ this.files = [];
71
+ this.loading = false;
72
+ this.error = null;
73
+ // Structured error for the current top-level listing: { status, detail, path }
74
+ this.errorInfo = null;
75
+ // Per-subtree expansion errors: Map<absPath, { status, detail }>
76
+ this.childrenError = new Map();
77
+
78
+ // Selection — absPath of the keyboard-selected item
79
+ this.selectedPath = null;
80
+
81
+ // Filters
82
+ this.showHidden = false;
83
+ this.filter = 'all'; // 'all' | 'code' | 'images' | 'docs' | 'folders'
84
+
85
+ // Inline filter (becomes a recursive search once query is ≥2 chars)
86
+ this.filterBarOpen = false;
87
+ this.filterQuery = '';
88
+
89
+ // Recursive tree index for subtree search (populated lazily per-cwd).
90
+ // Holds absolute paths of every file under the cwd (from /api/files/list).
91
+ this.treeIndex = null;
92
+ this.treeIndexCwd = null;
93
+ this.treeIndexLoading = false;
94
+ this.treeIndexError = null;
95
+ this.treeIndexTruncated = false;
96
+ // Cache key fragment that captures the index-affecting query params, so
97
+ // toggling "include ignored" forces a refetch instead of reusing stale.
98
+ this.treeIndexKey = null;
99
+
100
+ // Search behavior toggles (filter bar)
101
+ this.searchSubdirs = true; // false = only direct children of cwd
102
+ this.searchIncludeIgnored = false; // true = include .gitignore'd + hidden
103
+
104
+ // Breadcrumb path editor — typed path input swaps in for the segments
105
+ this.pathEditing = false;
106
+
107
+ // Git status — Map<absPath, 'M' | 'A' | 'D' | 'U' | 'S'> (modified/added/deleted/untracked/staged)
108
+ this.gitStatus = new Map();
109
+ this.gitBranch = null;
110
+ this.gitFetchedForCwd = null;
111
+
112
+ // Tree expansion — Set<absPath> for expanded dirs, Map<absPath, Files[]> for cached children
113
+ this.expanded = new Set();
114
+ this.childrenCache = new Map();
115
+ this.loadingChildren = new Set();
116
+
117
+ // Widget reference
118
+ this.widget = null;
119
+ this.currentContainer = null;
120
+ }
121
+
122
+ // Navigation — breadcrumbs only (back/forward history removed 2026-04-19)
123
+ navigateTo(path) {
124
+ this.currentPath = path;
125
+ this.selectedPath = null;
126
+ }
127
+ }
128
+
129
+ // Per-session state map
130
+ const states = new Map();
131
+
132
+ function getState(sessionId) {
133
+ if (!sessionId) sessionId = WidgetManager.currentSessionId;
134
+ if (!states.has(sessionId)) {
135
+ const s = new FileExplorerState();
136
+ states.set(sessionId, s);
137
+ }
138
+ return states.get(sessionId);
139
+ }
140
+
141
+ function destroyState(sessionId) {
142
+ states.delete(sessionId);
143
+ }
144
+
145
+ // ============================================================================
146
+ // Global Sort Preferences (persisted across sessions and reloads)
147
+ // ============================================================================
148
+
149
+ const SORT_PREFS_KEY = 'file-explorer-sort';
150
+ const SORT_FIELDS = ['name', 'modified', 'size', 'kind'];
151
+ const GROUP_MODES = ['dirs-first', 'files-first', 'mixed'];
152
+
153
+ function loadSortPrefs() {
154
+ try {
155
+ const raw = localStorage.getItem(SORT_PREFS_KEY);
156
+ if (raw) {
157
+ const p = JSON.parse(raw);
158
+ const hasField = SORT_FIELDS.includes(p.sortBy);
159
+ const hasDir = typeof p.sortAsc === 'boolean';
160
+ const group = GROUP_MODES.includes(p.groupDirs) ? p.groupDirs : 'dirs-first';
161
+ if (hasField && hasDir) {
162
+ return { sortBy: p.sortBy, sortAsc: p.sortAsc, groupDirs: group };
163
+ }
164
+ }
165
+ } catch {}
166
+ return { sortBy: 'name', sortAsc: true, groupDirs: 'dirs-first' };
167
+ }
168
+
169
+ let sortPrefs = loadSortPrefs();
170
+
171
+ function setSortPrefs(next) {
172
+ sortPrefs = { ...sortPrefs, ...next };
173
+ try {
174
+ localStorage.setItem(SORT_PREFS_KEY, JSON.stringify(sortPrefs));
175
+ } catch {}
176
+ renderContent();
177
+ }
178
+
179
+ // ============================================================================
180
+ // File Type Utilities
181
+ // ============================================================================
182
+
183
+ const FILE_TYPES = {
184
+ code: {
185
+ extensions: ['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.php', '.swift', '.kt', '.scala', '.vue', '.svelte', '.css', '.scss', '.sass', '.less', '.html', '.xml', '.json', '.yaml', '.yml', '.toml', '.sh', '.bash', '.zsh', '.fish', '.sql', '.graphql', '.proto'],
186
+ icon: 'code',
187
+ color: 'var(--accent-blue)',
188
+ label: 'Code'
189
+ },
190
+ images: {
191
+ extensions: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.heic', '.avif'],
192
+ icon: 'image',
193
+ color: 'var(--accent-purple)',
194
+ label: 'Image'
195
+ },
196
+ docs: {
197
+ extensions: ['.md', '.markdown', '.mdx', '.txt', '.doc', '.docx', '.pdf', '.rtf', '.tex', '.org', '.rst'],
198
+ icon: 'file-text',
199
+ color: 'var(--accent-green)',
200
+ label: 'Doc'
201
+ },
202
+ data: {
203
+ extensions: ['.csv', '.tsv', '.xls', '.xlsx', '.db', '.sqlite', '.parquet', '.arrow'],
204
+ icon: 'table',
205
+ color: 'var(--accent-orange)',
206
+ label: 'Data'
207
+ },
208
+ config: {
209
+ extensions: ['.env', '.gitignore', '.dockerignore', '.editorconfig', '.eslintrc', '.prettierrc', '.babelrc', 'Makefile', 'Dockerfile', '.npmrc', '.nvmrc'],
210
+ icon: 'settings',
211
+ color: 'var(--text-tertiary)',
212
+ label: 'Config'
213
+ },
214
+ archive: {
215
+ extensions: ['.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar'],
216
+ icon: 'archive',
217
+ color: 'var(--accent-yellow)',
218
+ label: 'Archive'
219
+ }
220
+ };
221
+
222
+ function getFileType(filename) {
223
+ const lower = filename.toLowerCase();
224
+ for (const [type, config] of Object.entries(FILE_TYPES)) {
225
+ if (config.extensions.some(ext => lower.endsWith(ext) || lower === ext.slice(1))) {
226
+ return { type, ...config };
227
+ }
228
+ }
229
+ return { type: 'other', icon: 'file', color: 'var(--text-secondary)', label: 'File' };
230
+ }
231
+
232
+ // Per-extension Kind labels — Finder-style. The bucket in FILE_TYPES still
233
+ // drives the row icon and color (so all source files share the "code" icon),
234
+ // but the *label* in the Kind column is specific. Anything not listed falls
235
+ // back to the uppercased extension (e.g. ".log" → "LOG"), which handles the
236
+ // long tail gracefully.
237
+ const EXTENSION_LABELS = {
238
+ // Markup / config / data
239
+ '.html': 'HTML', '.htm': 'HTML',
240
+ '.xml': 'XML',
241
+ '.json': 'JSON', '.jsonl': 'JSONL',
242
+ '.yaml': 'YAML', '.yml': 'YAML',
243
+ '.toml': 'TOML',
244
+ '.csv': 'CSV', '.tsv': 'TSV',
245
+ '.md': 'Markdown', '.markdown': 'Markdown', '.mdx': 'MDX',
246
+ '.txt': 'Text', '.rtf': 'Rich Text',
247
+ '.pdf': 'PDF',
248
+ // Source code
249
+ '.js': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript',
250
+ '.ts': 'TypeScript',
251
+ '.jsx': 'JSX', '.tsx': 'TSX',
252
+ '.py': 'Python',
253
+ '.rb': 'Ruby',
254
+ '.go': 'Go',
255
+ '.rs': 'Rust',
256
+ '.java': 'Java',
257
+ '.c': 'C', '.h': 'C Header',
258
+ '.cpp': 'C++', '.hpp': 'C++ Header',
259
+ '.cs': 'C#',
260
+ '.php': 'PHP',
261
+ '.swift': 'Swift',
262
+ '.kt': 'Kotlin',
263
+ '.scala': 'Scala',
264
+ '.vue': 'Vue', '.svelte': 'Svelte',
265
+ '.css': 'CSS', '.scss': 'SCSS', '.sass': 'Sass', '.less': 'Less',
266
+ '.sh': 'Shell', '.bash': 'Shell', '.zsh': 'Shell', '.fish': 'Shell',
267
+ '.sql': 'SQL',
268
+ '.graphql': 'GraphQL', '.proto': 'Protobuf',
269
+ // Images
270
+ '.png': 'PNG',
271
+ '.jpg': 'JPEG', '.jpeg': 'JPEG',
272
+ '.gif': 'GIF',
273
+ '.webp': 'WebP',
274
+ '.svg': 'SVG',
275
+ '.ico': 'Icon',
276
+ '.bmp': 'Bitmap',
277
+ '.tiff': 'TIFF',
278
+ '.heic': 'HEIC',
279
+ '.avif': 'AVIF',
280
+ // Data / archive
281
+ '.xls': 'Excel', '.xlsx': 'Excel',
282
+ '.db': 'Database', '.sqlite': 'SQLite',
283
+ '.parquet': 'Parquet', '.arrow': 'Arrow',
284
+ '.zip': 'ZIP', '.tar': 'TAR',
285
+ '.gz': 'Gzip', '.bz2': 'Bzip2', '.xz': 'XZ',
286
+ '.7z': '7-Zip', '.rar': 'RAR',
287
+ };
288
+
289
+ // User-facing label for the Kind column. Folders get "Folder"; otherwise try
290
+ // per-extension first, then the uppercased extension, then the bucket label
291
+ // for no-extension files like "Makefile" / "Dockerfile" (which match the
292
+ // config bucket via full-name extensions).
293
+ function getKindLabel(file) {
294
+ if (file.is_dir) return 'Folder';
295
+ const name = file.name;
296
+ const dot = name.lastIndexOf('.');
297
+ if (dot > 0 && dot < name.length - 1) {
298
+ const ext = name.slice(dot).toLowerCase();
299
+ if (EXTENSION_LABELS[ext]) return EXTENSION_LABELS[ext];
300
+ return name.slice(dot + 1).toUpperCase();
301
+ }
302
+ return getFileType(name).label;
303
+ }
304
+
305
+ // Sort key for the Kind column. Folders bucket together regardless of name
306
+ // (independent of the dirs-first grouping toggle). Files sort by their Kind
307
+ // *label* so HTML rows cluster with HTML, JSON with JSON, etc., instead of
308
+ // the previous coarse "all code together" behavior.
309
+ function getKindSortKey(file) {
310
+ if (file.is_dir) return 'folder';
311
+ return getKindLabel(file).toLowerCase();
312
+ }
313
+
314
+ function formatFileSize(bytes) {
315
+ if (bytes === 0) return '0 B';
316
+ if (bytes === undefined || bytes === null) return '';
317
+ const units = ['B', 'KB', 'MB', 'GB'];
318
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
319
+ return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
320
+ }
321
+
322
+ // Friendly user-facing description of a fetchDirectory error. Falls back to
323
+ // the raw detail or status when the server didn't send a recognized string.
324
+ function describeDirError(errInfo) {
325
+ if (!errInfo) return null;
326
+ const { status, detail } = errInfo;
327
+ if (status === 403 || detail === 'Path not allowed') {
328
+ return {
329
+ icon: 'warning',
330
+ title: 'Access denied',
331
+ body: detail === 'Path not allowed'
332
+ ? 'This path is outside the allowed roots for this session.'
333
+ : (detail || 'You don\'t have permission to view this folder.'),
334
+ };
335
+ }
336
+ if (status === 404 || detail === 'Path not found') {
337
+ return {
338
+ icon: 'alert',
339
+ title: 'Folder not found',
340
+ body: 'The path no longer exists.',
341
+ };
342
+ }
343
+ if (status === 400 && detail === 'Not a directory') {
344
+ return {
345
+ icon: 'file',
346
+ title: 'Not a folder',
347
+ body: 'This path points to a file, not a directory — try previewing it instead.',
348
+ };
349
+ }
350
+ return {
351
+ icon: 'alert',
352
+ title: 'Couldn\'t load folder',
353
+ body: detail || `Server returned HTTP ${status || '?'}`,
354
+ };
355
+ }
356
+
357
+ function matchesFilter(file, filter) {
358
+ if (filter === 'all') return true;
359
+ if (filter === 'folders') return file.is_dir;
360
+ const fileType = getFileType(file.name);
361
+ return fileType.type === filter;
362
+ }
363
+
364
+ // ============================================================================
365
+ // API Functions
366
+ // ============================================================================
367
+
368
+ async function fetchDirectory(path) {
369
+ const response = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
370
+ if (!response.ok) {
371
+ let detail;
372
+ try { detail = (await response.json())?.detail; } catch {}
373
+ const err = new Error(detail || `HTTP ${response.status}`);
374
+ err.status = response.status;
375
+ err.detail = detail;
376
+ err.path = path;
377
+ throw err;
378
+ }
379
+ const data = await response.json();
380
+ return data.files || [];
381
+ }
382
+
383
+ async function fetchFileContent(path) {
384
+ try {
385
+ const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
386
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
387
+ const data = await response.json();
388
+ return data.content;
389
+ } catch (err) {
390
+ console.error('Failed to fetch file:', err);
391
+ throw err;
392
+ }
393
+ }
394
+
395
+ async function fetchGitStatus(cwd) {
396
+ try {
397
+ const response = await fetch(`/api/git/status?cwd=${encodeURIComponent(cwd)}`);
398
+ if (!response.ok) return null;
399
+ const data = await response.json();
400
+ if (data.error === 'not_a_repo') return null;
401
+ return data;
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+
407
+ async function refreshGitStatus(force = false) {
408
+ const state = getState();
409
+ if (!state.cwd) return;
410
+ if (!force && state.gitFetchedForCwd === state.cwd) return;
411
+ const data = await fetchGitStatus(state.cwd);
412
+ state.gitFetchedForCwd = state.cwd;
413
+ state.gitStatus = new Map();
414
+ state.gitBranch = null;
415
+ if (!data) return;
416
+ state.gitBranch = data.branch || null;
417
+ const root = data.root || state.cwd;
418
+ // git always reports '/'-separated paths; resolvePath re-emits them with
419
+ // the server's own separator so these keys match the tree's, which are
420
+ // built the same way. A raw concat left 'C:\proj\src/app.js' — a key
421
+ // nothing ever looked up, so every file showed as unmodified.
422
+ const join = (rel) => resolvePath(root, rel);
423
+ // Precedence: staged > modified > untracked (staged+modified both flagged as 'M' is fine)
424
+ (data.untracked || []).forEach(f => state.gitStatus.set(join(f.path), 'U'));
425
+ (data.modified || []).forEach(f => state.gitStatus.set(join(f.path), f.status === 'deleted' ? 'D' : 'M'));
426
+ (data.staged || []).forEach(f => {
427
+ const map = { added: 'A', deleted: 'D', renamed: 'M', copied: 'M' };
428
+ state.gitStatus.set(join(f.path), map[f.status] || 'S');
429
+ });
430
+ }
431
+
432
+ // Roll up child statuses into a single label for the directory.
433
+ // - any A/M/D/S child → 'M' (the dir contains real changes)
434
+ // - only U children → 'U' (the dir is purely untracked content)
435
+ // Untracked-only dirs render in default color so the user spec
436
+ // ("white = untracked") propagates up to the parent.
437
+ function dirHasGitChanges(dirPath) {
438
+ const state = getState();
439
+ if (state.gitStatus.size === 0) return null;
440
+ let sawAny = false;
441
+ for (const [p, s] of state.gitStatus) {
442
+ if (p === dirPath || !isUnder(p, dirPath)) continue;
443
+ if (s !== 'U') return 'M';
444
+ sawAny = true;
445
+ }
446
+ return sawAny ? 'U' : null;
447
+ }
448
+
449
+ // Lazy-fetch a flat list of every file under the cwd. Used by the inline filter
450
+ // to find matches anywhere in the subtree (not just the loaded/expanded nodes).
451
+ // Cached per-cwd; invalidated on Refresh and on cwd change.
452
+ async function ensureTreeIndex() {
453
+ const state = getState();
454
+ const cwd = state.cwd;
455
+ if (!cwd) return;
456
+ const key = `${cwd}|ignored=${state.searchIncludeIgnored ? 1 : 0}`;
457
+ if (state.treeIndexKey === key && state.treeIndex !== null) return;
458
+ if (state.treeIndexLoading && state.treeIndexKey === key) return;
459
+
460
+ state.treeIndexLoading = true;
461
+ state.treeIndexCwd = cwd;
462
+ state.treeIndexKey = key;
463
+ state.treeIndexError = null;
464
+ renderList();
465
+
466
+ try {
467
+ const url = `/api/files/list?cwd=${encodeURIComponent(cwd)}`
468
+ + (state.searchIncludeIgnored ? '&include_ignored=true' : '');
469
+ const response = await fetch(url);
470
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
471
+ const data = await response.json();
472
+ // `files` entries are either relative to cwd or already absolute (from extra_dirs)
473
+ state.treeIndex = (data.files || []).map(f => resolvePath(data.cwd || cwd, f));
474
+ state.treeIndexTruncated = !!data.truncated;
475
+ } catch (err) {
476
+ console.error('Failed to fetch tree index:', err);
477
+ state.treeIndex = [];
478
+ state.treeIndexError = err.message || 'Failed to load file index';
479
+ } finally {
480
+ state.treeIndexLoading = false;
481
+ renderList();
482
+ }
483
+ }
484
+
485
+ function invalidateTreeIndex() {
486
+ const state = getState();
487
+ state.treeIndex = null;
488
+ state.treeIndexCwd = null;
489
+ state.treeIndexKey = null;
490
+ state.treeIndexError = null;
491
+ state.treeIndexTruncated = false;
492
+ }
493
+
494
+ const FLAT_MATCH_LIMIT = 300;
495
+
496
+ // Score a path against the lowercased query. Higher is better.
497
+ // Combines: filename-vs-path bucket, exact/stem/prefix bonuses, and a depth
498
+ // penalty so root-level files outrank equivalents buried deep in the tree.
499
+ // `cwd` lets us measure depth relative to the project root, not the FS root.
500
+ function scoreMatch(path, q, cwd) {
501
+ const lower = path.toLowerCase();
502
+ const name = basename(lower);
503
+ // Stem = filename without extension (e.g. "readme" from "readme.md").
504
+ const dot = name.lastIndexOf('.');
505
+ const stem = dot > 0 ? name.slice(0, dot) : name;
506
+
507
+ let score;
508
+ if (name === q || stem === q) score = 1000; // exact filename or stem
509
+ else if (name.startsWith(q)) score = 500; // filename starts-with
510
+ else if (name.includes(q)) score = 200; // filename contains
511
+ else if (lower.includes(q)) score = 50; // path-only match
512
+ else return -Infinity; // shouldn't happen (already filtered)
513
+
514
+ // Depth penalty — count slashes in the *relative* path (so paths inside
515
+ // the project root are favored over paths from extra_dirs / nested deep).
516
+ // relativeTo hands back an unchanged (absolute) path when it isn't under
517
+ // cwd, and that extra leading segment is exactly the penalty that keeps
518
+ // extra_dirs hits below in-project ones.
519
+ const rel = cwd ? relativeTo(lower, cwd) : lower;
520
+ const depth = splitPath(rel, path).length - 1;
521
+ score -= depth * 10;
522
+
523
+ return score;
524
+ }
525
+
526
+ function filterTreeIndex(query) {
527
+ const state = getState();
528
+ if (!state.treeIndex) return null;
529
+ const q = query.toLowerCase();
530
+ const cwd = (state.cwd || state.currentPath || '').toLowerCase();
531
+ const subdirsOff = !state.searchSubdirs;
532
+
533
+ const matches = [];
534
+ for (const p of state.treeIndex) {
535
+ const lower = p.toLowerCase();
536
+ if (!lower.includes(q)) continue;
537
+ if (subdirsOff) {
538
+ // Only direct children of cwd: one segment below it, no deeper.
539
+ if (!cwd || !isUnder(lower, cwd)) continue;
540
+ if (splitPath(relativeTo(lower, cwd), lower).length !== 1) continue;
541
+ }
542
+ matches.push(p);
543
+ }
544
+ matches.sort((a, b) => {
545
+ const sa = scoreMatch(a, q, cwd);
546
+ const sb = scoreMatch(b, q, cwd);
547
+ if (sa !== sb) return sb - sa; // higher score first
548
+ if (a.length !== b.length) return a.length - b.length; // shorter path wins ties
549
+ return a.localeCompare(b);
550
+ });
551
+ return matches.slice(0, FLAT_MATCH_LIMIT);
552
+ }
553
+
554
+ // ============================================================================
555
+ // Rendering Functions
556
+ // ============================================================================
557
+
558
+ function renderToolbar(container) {
559
+ const state = getState();
560
+ const toolbar = document.createElement('div');
561
+ toolbar.className = 'fe-toolbar';
562
+
563
+ // File-type filter dropdown
564
+ const filterSelect = document.createElement('select');
565
+ filterSelect.className = 'fe-filter';
566
+ filterSelect.innerHTML = `
567
+ <option value="all" ${state.filter === 'all' ? 'selected' : ''}>All Files</option>
568
+ <option value="code" ${state.filter === 'code' ? 'selected' : ''}>Code</option>
569
+ <option value="images" ${state.filter === 'images' ? 'selected' : ''}>Images</option>
570
+ <option value="docs" ${state.filter === 'docs' ? 'selected' : ''}>Docs</option>
571
+ <option value="folders" ${state.filter === 'folders' ? 'selected' : ''}>Folders</option>
572
+ `;
573
+ filterSelect.onchange = () => {
574
+ state.filter = filterSelect.value;
575
+ renderContent();
576
+ };
577
+ toolbar.appendChild(filterSelect);
578
+
579
+ // Actions
580
+ const actions = document.createElement('div');
581
+ actions.className = 'fe-actions';
582
+
583
+ // Grouping: dirs-first / files-first / mixed — cycles on click
584
+ const groupBtn = document.createElement('button');
585
+ groupBtn.className = 'fe-action-btn fe-group-btn';
586
+ const modeLabels = { 'dirs-first': 'Dirs first', 'files-first': 'Files first', 'mixed': 'Mixed' };
587
+ groupBtn.setAttribute('data-tooltip', `Grouping: ${modeLabels[sortPrefs.groupDirs]} (click to cycle)`);
588
+ groupBtn.innerHTML = GROUP_ICONS[sortPrefs.groupDirs];
589
+ groupBtn.onclick = () => {
590
+ const idx = GROUP_MODES.indexOf(sortPrefs.groupDirs);
591
+ const next = GROUP_MODES[(idx + 1) % GROUP_MODES.length];
592
+ setSortPrefs({ groupDirs: next });
593
+ };
594
+ actions.appendChild(groupBtn);
595
+
596
+ // Hidden files toggle
597
+ const hiddenBtn = document.createElement('button');
598
+ hiddenBtn.className = `fe-action-btn ${state.showHidden ? 'active' : ''}`;
599
+ hiddenBtn.setAttribute('data-tooltip', 'Show Hidden Files');
600
+ hiddenBtn.innerHTML = ICONS.eye || '👁';
601
+ hiddenBtn.onclick = () => {
602
+ state.showHidden = !state.showHidden;
603
+ renderContent();
604
+ };
605
+ actions.appendChild(hiddenBtn);
606
+
607
+ // Refresh — in-place so selection/expansion/scroll are preserved, and
608
+ // expanded subtrees also refetch (not just the top level).
609
+ const refreshBtn = document.createElement('button');
610
+ refreshBtn.className = 'fe-action-btn';
611
+ refreshBtn.setAttribute('data-tooltip', 'Refresh');
612
+ refreshBtn.innerHTML = ICONS.refresh || '↻';
613
+ refreshBtn.onclick = () => refreshInPlace({ force: true });
614
+ actions.appendChild(refreshBtn);
615
+
616
+ toolbar.appendChild(actions);
617
+ container.appendChild(toolbar);
618
+ }
619
+
620
+ function renderBreadcrumb(container) {
621
+ const state = getState();
622
+
623
+ const breadcrumb = document.createElement('div');
624
+ breadcrumb.className = 'fe-breadcrumb';
625
+
626
+ if (state.pathEditing) {
627
+ renderPathEditor(breadcrumb);
628
+ } else {
629
+ renderPathSegments(breadcrumb);
630
+ renderEditPathButton(breadcrumb);
631
+ }
632
+
633
+ container.appendChild(breadcrumb);
634
+ }
635
+
636
+ function renderPathSegments(parent) {
637
+ const state = getState();
638
+ const pathContainer = document.createElement('div');
639
+ pathContainer.className = 'fe-path';
640
+
641
+ if (state.currentPath) {
642
+ // Segments hang off the root ('/' or 'C:\'), which is a crumb of its
643
+ // own rather than a name — splitting it into a segment is what turned
644
+ // a Windows drive letter into a directory called "C:".
645
+ const root = pathRoot(state.currentPath);
646
+ const parts = splitPath(state.currentPath.slice(root.length), state.currentPath);
647
+ let accumulated = root ? stripTrailingSep(root) : '';
648
+
649
+ const homeBtn = document.createElement('button');
650
+ homeBtn.className = 'fe-path-segment fe-home';
651
+ homeBtn.innerHTML = ICONS.home || '🏠';
652
+ homeBtn.setAttribute('data-tooltip', state.cwd || '/');
653
+ homeBtn.onclick = () => loadDirectory(state.cwd || '/');
654
+ pathContainer.appendChild(homeBtn);
655
+
656
+ const sepChar = pathSep(state.currentPath);
657
+ parts.forEach(part => {
658
+ accumulated = joinPath(accumulated || root, part);
659
+ const path = accumulated;
660
+
661
+ const sep = document.createElement('span');
662
+ sep.className = 'fe-path-sep';
663
+ sep.textContent = sepChar;
664
+ pathContainer.appendChild(sep);
665
+
666
+ const segment = document.createElement('button');
667
+ segment.className = 'fe-path-segment';
668
+ segment.textContent = part;
669
+ segment.onclick = () => loadDirectory(path);
670
+ pathContainer.appendChild(segment);
671
+ });
672
+ }
673
+
674
+ parent.appendChild(pathContainer);
675
+ }
676
+
677
+ function renderEditPathButton(parent) {
678
+ const btn = document.createElement('button');
679
+ btn.className = 'fe-path-edit-btn';
680
+ btn.setAttribute('data-tooltip', 'Edit path');
681
+ btn.innerHTML = ICONS.edit || '✎';
682
+ btn.onclick = startPathEdit;
683
+ parent.appendChild(btn);
684
+ }
685
+
686
+ function renderPathEditor(parent) {
687
+ const state = getState();
688
+ const input = document.createElement('input');
689
+ input.type = 'text';
690
+ input.className = 'fe-path-input';
691
+ input.value = state.currentPath || state.cwd || '/';
692
+ input.spellcheck = false;
693
+ input.autocomplete = 'off';
694
+ input.setAttribute('aria-label', 'Type a path and press Enter');
695
+
696
+ input.addEventListener('keydown', (e) => {
697
+ if (e.key === 'Enter') {
698
+ e.preventDefault();
699
+ e.stopPropagation();
700
+ commitPath(input.value);
701
+ } else if (e.key === 'Escape') {
702
+ // Stop here so the widget-level Escape doesn't also fire (which
703
+ // would try to close the filter bar or the widget itself).
704
+ e.preventDefault();
705
+ e.stopPropagation();
706
+ cancelPathEdit();
707
+ }
708
+ });
709
+
710
+ input.addEventListener('blur', () => {
711
+ // If we're still editing, the user clicked away → cancel.
712
+ // commit / cancel flip pathEditing off first so this is a no-op in
713
+ // those paths.
714
+ if (getState().pathEditing) cancelPathEdit();
715
+ });
716
+
717
+ parent.appendChild(input);
718
+
719
+ // Focus after it's in the DOM; select-all so a quick retype replaces it.
720
+ setTimeout(() => {
721
+ input.focus();
722
+ input.select();
723
+ }, 0);
724
+ }
725
+
726
+ function startPathEdit() {
727
+ const state = getState();
728
+ state.pathEditing = true;
729
+ renderContent();
730
+ }
731
+
732
+ function cancelPathEdit() {
733
+ const state = getState();
734
+ state.pathEditing = false;
735
+ renderContent();
736
+ state.currentContainer?.focus({ preventScroll: true });
737
+ }
738
+
739
+ /**
740
+ * The server-reported HOME, or a stand-in built from the session cwd while
741
+ * the "connected" WebSocket message hasn't landed yet. The stand-in is the
742
+ * first two segments below the root — /home/<user>, C:\Users\<user> — which
743
+ * is why it counts segments off `pathRoot` instead of slicing the string:
744
+ * a drive letter is a root, not a segment.
745
+ */
746
+ function homeGuess() {
747
+ const state = getState();
748
+ if (CONFIG.HOME && CONFIG.HOME !== '/home') return CONFIG.HOME;
749
+ if (!state.cwd) return '/';
750
+ const root = pathRoot(state.cwd);
751
+ const parts = splitPath(state.cwd.slice(root.length), state.cwd).slice(0, 2);
752
+ return parts.reduce((acc, part) => joinPath(acc, part), stripTrailingSep(root) || root || '');
753
+ }
754
+
755
+ function commitPath(raw) {
756
+ const state = getState();
757
+ state.pathEditing = false;
758
+ const trimmed = (raw || '').trim();
759
+ if (!trimmed) { renderContent(); return; }
760
+
761
+ let resolved = trimmed;
762
+ if (resolved.startsWith('~')) {
763
+ // "~" or "~/path" — expand to the real server-reported HOME that
764
+ // arrives with the "connected" WebSocket message. Falls back to the
765
+ // first two segments of the session cwd (/home/<user>) if HOME
766
+ // hasn't been populated yet.
767
+ resolved = homeGuess() + resolved.slice(1);
768
+ }
769
+ // Relative input hangs off the current directory; an absolute path (or
770
+ // the ~-expansion above) just passes through. Either way resolvePath
771
+ // re-emits with the server's separator and drops any trailing one, so a
772
+ // typed '/' still lands on a Windows server.
773
+ resolved = resolvePath(state.currentPath || state.cwd || pathRoot(state.cwd) || '/', resolved);
774
+
775
+ loadDirectory(resolved);
776
+ }
777
+
778
+ function sortFiles(files) {
779
+ const sorted = [...files];
780
+ sorted.sort((a, b) => {
781
+ // Group dirs and files before applying field sort
782
+ if (sortPrefs.groupDirs === 'dirs-first') {
783
+ if (a.is_dir && !b.is_dir) return -1;
784
+ if (!a.is_dir && b.is_dir) return 1;
785
+ } else if (sortPrefs.groupDirs === 'files-first') {
786
+ if (a.is_dir && !b.is_dir) return 1;
787
+ if (!a.is_dir && b.is_dir) return -1;
788
+ }
789
+ // else 'mixed' — no grouping, fall through
790
+
791
+ let cmp = 0;
792
+ switch (sortPrefs.sortBy) {
793
+ case 'modified':
794
+ cmp = (a.mtime || 0) - (b.mtime || 0);
795
+ break;
796
+ case 'size':
797
+ cmp = (a.size || 0) - (b.size || 0);
798
+ break;
799
+ case 'kind':
800
+ // Dirs bucket ahead of every file kind, and that has to be
801
+ // its own comparison rather than a magic prefix on the sort
802
+ // key: localeCompare ignores the characters you'd reach for
803
+ // (a leading NUL collates to literally nothing, so '\0folder'
804
+ // and 'folder' compare equal).
805
+ cmp = (a.is_dir ? 0 : 1) - (b.is_dir ? 0 : 1) ||
806
+ getKindSortKey(a).localeCompare(getKindSortKey(b));
807
+ break;
808
+ default:
809
+ cmp = a.name.localeCompare(b.name);
810
+ }
811
+ if (cmp === 0) cmp = a.name.localeCompare(b.name);
812
+ return sortPrefs.sortAsc ? cmp : -cmp;
813
+ });
814
+ return sorted;
815
+ }
816
+
817
+ function filterFiles(files) {
818
+ const state = getState();
819
+ return files
820
+ .filter(f => matchesFilter(f, state.filter))
821
+ .filter(f => state.showHidden || !f.name.startsWith('.'));
822
+ }
823
+
824
+ // Inline filter: does this branch contain a match for the query?
825
+ // Recurses through expanded children (we can only filter what's loaded).
826
+ function branchMatches(file, absPath, query) {
827
+ if (!query) return true;
828
+ const q = query.toLowerCase();
829
+ if (file.name.toLowerCase().includes(q)) return true;
830
+ if (!file.is_dir) return false;
831
+ const state = getState();
832
+ if (!state.expanded.has(absPath)) return false;
833
+ const children = state.childrenCache.get(absPath) || [];
834
+ for (const c of children) {
835
+ const childPath = c.path || joinPath(absPath, c.name);
836
+ if (branchMatches(c, childPath, query)) return true;
837
+ }
838
+ return false;
839
+ }
840
+
841
+ function renderFilterBar(container) {
842
+ const state = getState();
843
+ if (!state.filterBarOpen) return;
844
+ const bar = document.createElement('div');
845
+ bar.className = 'fe-filter-bar';
846
+ const subdirsActive = state.searchSubdirs;
847
+ const ignoredActive = state.searchIncludeIgnored;
848
+ const subdirsTip = subdirsActive
849
+ ? (S.file_explorer?.search_subdirs_on || 'Searching subfolders (click to limit to current folder)')
850
+ : (S.file_explorer?.search_subdirs_off || 'Limited to current folder (click to search subfolders)');
851
+ const ignoredTip = ignoredActive
852
+ ? (S.file_explorer?.include_ignored_on || 'Including .gitignore\'d files (click to respect .gitignore)')
853
+ : (S.file_explorer?.include_ignored_off || 'Respecting .gitignore (click to include ignored files)');
854
+ bar.innerHTML = `
855
+ <span class="fe-filter-icon">${ICONS.search || '🔍'}</span>
856
+ <input type="text" class="fe-filter-input" placeholder="Filter tree…" />
857
+ <button class="fe-filter-toggle ${subdirsActive ? 'active' : ''}" data-toggle="subdirs" data-tooltip="${subdirsTip}">
858
+ ${ICONS.folderTree || '🌳'}
859
+ </button>
860
+ <button class="fe-filter-toggle ${ignoredActive ? 'active' : ''}" data-toggle="ignored" data-tooltip="${ignoredTip}">
861
+ ${ignoredActive ? (ICONS.eye || '👁') : (ICONS.eyeOff || '🚫')}
862
+ </button>
863
+ <button class="fe-filter-clear" data-tooltip="Clear filter (Esc)">✕</button>
864
+ `;
865
+ const input = bar.querySelector('.fe-filter-input');
866
+ input.value = state.filterQuery;
867
+ input.addEventListener('input', (e) => {
868
+ state.filterQuery = e.target.value;
869
+ // Re-render just the list (keeps the filter input focused and caret stable)
870
+ renderList();
871
+ });
872
+ bar.querySelector('[data-toggle="subdirs"]').addEventListener('click', (e) => {
873
+ e.preventDefault();
874
+ state.searchSubdirs = !state.searchSubdirs;
875
+ renderContent();
876
+ // Refocus the input so typing continues without an extra click
877
+ state.currentContainer?.querySelector('.fe-filter-input')?.focus();
878
+ });
879
+ bar.querySelector('[data-toggle="ignored"]').addEventListener('click', (e) => {
880
+ e.preventDefault();
881
+ state.searchIncludeIgnored = !state.searchIncludeIgnored;
882
+ // Index params changed — drop the cache so the next ensureTreeIndex refetches
883
+ invalidateTreeIndex();
884
+ ensureTreeIndex();
885
+ renderContent();
886
+ state.currentContainer?.querySelector('.fe-filter-input')?.focus();
887
+ });
888
+ input.addEventListener('keydown', (e) => {
889
+ if (e.key === 'Escape') {
890
+ e.preventDefault();
891
+ e.stopPropagation();
892
+ closeFilterBar();
893
+ return;
894
+ }
895
+ // ArrowDown from the filter input jumps to the first visible result.
896
+ // ArrowUp jumps to the last. Enter acts on the first match.
897
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
898
+ const items = getVisibleItems();
899
+ if (items.length === 0) return;
900
+ e.preventDefault();
901
+ const state = getState();
902
+ state.currentContainer?.focus({ preventScroll: true });
903
+ const target = e.key === 'ArrowUp' ? items[items.length - 1] : items[0];
904
+ setSelection(target.dataset.path);
905
+ if (e.key === 'Enter') activateSelection();
906
+ }
907
+ });
908
+ bar.querySelector('.fe-filter-clear').onclick = () => closeFilterBar();
909
+ container.appendChild(bar);
910
+ }
911
+
912
+ function closeFilterBar() {
913
+ const state = getState();
914
+ state.filterBarOpen = false;
915
+ state.filterQuery = '';
916
+ renderContent();
917
+ state.currentContainer?.focus({ preventScroll: true });
918
+ }
919
+
920
+ function openFilterBar(initialChar = '') {
921
+ const state = getState();
922
+ state.filterBarOpen = true;
923
+ if (initialChar) state.filterQuery = initialChar;
924
+ // Kick off the recursive index fetch early so the second keystroke has
925
+ // results to match against — /api/files/list is cached server-side per-cwd.
926
+ ensureTreeIndex();
927
+ renderContent();
928
+ const input = state.currentContainer?.querySelector('.fe-filter-input');
929
+ if (input) {
930
+ input.focus();
931
+ input.setSelectionRange(input.value.length, input.value.length);
932
+ }
933
+ }
934
+
935
+ function renderHeaderRow(list) {
936
+ const header = document.createElement('div');
937
+ header.className = 'fe-header-row';
938
+ const cols = [
939
+ { field: 'name', label: 'Name', cls: 'fe-header-name' },
940
+ { field: 'kind', label: 'Kind', cls: 'fe-header-kind' },
941
+ { field: 'modified', label: 'Modified', cls: 'fe-header-modified' },
942
+ { field: 'size', label: 'Size', cls: 'fe-header-size' },
943
+ ];
944
+ cols.forEach(c => {
945
+ const btn = document.createElement('button');
946
+ const active = sortPrefs.sortBy === c.field;
947
+ btn.className = `fe-header-col ${c.cls} ${active ? 'active' : ''}`;
948
+ const indicator = active
949
+ ? (sortPrefs.sortAsc ? '<span class="fe-header-caret">▲</span>' : '<span class="fe-header-caret">▼</span>')
950
+ : '';
951
+ btn.innerHTML = `<span class="fe-header-label">${c.label}</span>${indicator}`;
952
+ btn.onclick = () => {
953
+ if (sortPrefs.sortBy === c.field) {
954
+ setSortPrefs({ sortAsc: !sortPrefs.sortAsc });
955
+ } else {
956
+ setSortPrefs({ sortBy: c.field });
957
+ }
958
+ };
959
+ header.appendChild(btn);
960
+ });
961
+ list.appendChild(header);
962
+ }
963
+
964
+ function renderTreeNode(list, file, depth) {
965
+ const state = getState();
966
+ const absPath = file.path || joinPath(state.currentPath, file.name);
967
+ const query = state.filterBarOpen ? state.filterQuery : '';
968
+ if (query && !branchMatches(file, absPath, query)) return;
969
+
970
+ const item = renderFileItem(file, depth);
971
+ list.appendChild(item);
972
+
973
+ if (!file.is_dir) return;
974
+ if (!state.expanded.has(absPath)) return;
975
+
976
+ const children = state.childrenCache.get(absPath);
977
+ if (children === undefined) {
978
+ // Fetch in progress — placeholder
979
+ if (state.loadingChildren.has(absPath)) {
980
+ const loader = document.createElement('div');
981
+ loader.className = 'fe-item fe-tree-loading';
982
+ loader.style.paddingLeft = `${16 + (depth + 1) * 18}px`;
983
+ loader.textContent = 'Loading…';
984
+ list.appendChild(loader);
985
+ }
986
+ return;
987
+ }
988
+ // Subtree fetch errored — show a concise inline notice instead of an
989
+ // empty dir, so the user understands why it won't open.
990
+ const childErr = state.childrenError.get(absPath);
991
+ if (childErr) {
992
+ const desc = describeDirError(childErr);
993
+ const row = document.createElement('div');
994
+ row.className = 'fe-item fe-tree-error';
995
+ row.style.paddingLeft = `${16 + (depth + 1) * 18}px`;
996
+ row.textContent = desc?.title || 'Can\'t open';
997
+ row.setAttribute('data-tooltip', desc?.body || childErr.detail || '');
998
+ list.appendChild(row);
999
+ return;
1000
+ }
1001
+ const rendered = sortFiles(filterFiles(children));
1002
+ const anyMatching = query
1003
+ ? rendered.filter(c => branchMatches(c, c.path || joinPath(absPath, c.name), query))
1004
+ : rendered;
1005
+ if (anyMatching.length === 0 && !query) {
1006
+ const empty = document.createElement('div');
1007
+ empty.className = 'fe-item fe-tree-empty';
1008
+ empty.style.paddingLeft = `${16 + (depth + 1) * 18}px`;
1009
+ empty.textContent = 'Empty';
1010
+ list.appendChild(empty);
1011
+ }
1012
+ rendered.forEach(c => renderTreeNode(list, c, depth + 1));
1013
+ }
1014
+
1015
+ function renderFileList(container) {
1016
+ const state = getState();
1017
+ const query = state.filterBarOpen ? state.filterQuery : '';
1018
+
1019
+ // Recursive-search mode: ≥2 chars flips from in-memory tree filter to the
1020
+ // flat match list backed by /api/files/list. Kicks off a fetch on demand;
1021
+ // while loading, show a loader. Column headers are suppressed because the
1022
+ // results are flat (not tree-aligned with sortable columns).
1023
+ if (query && query.length >= 2) {
1024
+ ensureTreeIndex();
1025
+ const matches = filterTreeIndex(query);
1026
+ renderFlatMatches(container, matches);
1027
+ return;
1028
+ }
1029
+
1030
+ // Column headers: outside the scroll area so they never scroll away
1031
+ renderHeaderRow(container);
1032
+
1033
+ const list = document.createElement('div');
1034
+ list.className = 'fe-list';
1035
+
1036
+ // Top-level error block: listing itself couldn't be fetched (403/404/etc).
1037
+ // Rendered inside .fe-list so it gets the same layout/scroll container.
1038
+ if (state.errorInfo) {
1039
+ renderErrorBlock(list, state.errorInfo);
1040
+ container.appendChild(list);
1041
+ return;
1042
+ }
1043
+
1044
+ const files = sortFiles(filterFiles(state.files));
1045
+
1046
+ // Parent directory (always visible, never filtered)
1047
+ if (state.currentPath && state.currentPath !== '/' && state.currentPath !== state.cwd) {
1048
+ const parentPath = parentOf(state.currentPath) || pathRoot(state.currentPath) || '/';
1049
+ const parent = document.createElement('div');
1050
+ parent.className = 'fe-item fe-parent';
1051
+ parent.innerHTML = `
1052
+ <span class="fe-tree-spacer"></span>
1053
+ <span class="fe-icon">${ICONS.folderUp || '📁'}</span>
1054
+ <span class="fe-name">..</span>
1055
+ `;
1056
+ parent.onclick = () => loadDirectory(parentPath);
1057
+ list.appendChild(parent);
1058
+ }
1059
+
1060
+ // Files (possibly with expanded subtrees; renderTreeNode applies filter)
1061
+ files.forEach(file => renderTreeNode(list, file, 0));
1062
+
1063
+ // Empty states
1064
+ const hasItems = list.querySelector('.fe-item:not(.fe-parent)');
1065
+ if (!hasItems) {
1066
+ const empty = document.createElement('div');
1067
+ empty.className = 'fe-empty';
1068
+ if (query) {
1069
+ empty.innerHTML = `
1070
+ <span class="fe-empty-icon">${ICONS.search || '🔍'}</span>
1071
+ <span class="fe-empty-text">No matches for "${escapeHtml(query)}"</span>
1072
+ `;
1073
+ } else {
1074
+ empty.innerHTML = `
1075
+ <span class="fe-empty-icon">${ICONS.folder || '📂'}</span>
1076
+ <span class="fe-empty-text">${state.filter !== 'all' ? 'No matching files' : 'Empty directory'}</span>
1077
+ `;
1078
+ }
1079
+ list.appendChild(empty);
1080
+ }
1081
+
1082
+ container.appendChild(list);
1083
+ }
1084
+
1085
+ function renderErrorBlock(list, errInfo) {
1086
+ const state = getState();
1087
+ const desc = describeDirError(errInfo);
1088
+ const block = document.createElement('div');
1089
+ block.className = 'fe-error';
1090
+
1091
+ const iconSvg = ICONS[desc.icon] || ICONS.alert || '⚠';
1092
+ const pathText = errInfo.path ? escapeHtml(errInfo.path) : '';
1093
+
1094
+ block.innerHTML = `
1095
+ <div class="fe-error-icon">${iconSvg}</div>
1096
+ <div class="fe-error-title">${escapeHtml(desc.title)}</div>
1097
+ <div class="fe-error-body">${escapeHtml(desc.body)}</div>
1098
+ ${pathText ? `<div class="fe-error-path">${pathText}</div>` : ''}
1099
+ <div class="fe-error-actions"></div>
1100
+ `;
1101
+
1102
+ const actions = block.querySelector('.fe-error-actions');
1103
+ const isFilePath = errInfo.status === 400 && errInfo.detail === 'Not a directory';
1104
+
1105
+ if (isFilePath && errInfo.path) {
1106
+ const previewBtn = document.createElement('button');
1107
+ previewBtn.className = 'fe-error-btn fe-error-btn-primary';
1108
+ previewBtn.textContent = 'Preview file';
1109
+ previewBtn.onclick = () => {
1110
+ const parent = parentOf(errInfo.path) || pathRoot(errInfo.path) || '/';
1111
+ loadDirectory(parent);
1112
+ window.app?.previewFile(errInfo.path, { imageGallery: 'dir' });
1113
+ };
1114
+ actions.appendChild(previewBtn);
1115
+ }
1116
+
1117
+ const parentPath = (errInfo.path && parentOf(errInfo.path)) || '';
1118
+ if (parentPath && parentPath !== errInfo.path && parentPath !== '') {
1119
+ const parentBtn = document.createElement('button');
1120
+ parentBtn.className = 'fe-error-btn';
1121
+ parentBtn.textContent = 'Go to parent';
1122
+ parentBtn.onclick = () => loadDirectory(parentPath || '/');
1123
+ actions.appendChild(parentBtn);
1124
+ }
1125
+
1126
+ if (state.cwd && errInfo.path !== state.cwd) {
1127
+ const homeBtn = document.createElement('button');
1128
+ homeBtn.className = `fe-error-btn ${isFilePath ? '' : 'fe-error-btn-primary'}`;
1129
+ homeBtn.textContent = 'Go to project root';
1130
+ homeBtn.onclick = () => loadDirectory(state.cwd);
1131
+ actions.appendChild(homeBtn);
1132
+ }
1133
+
1134
+ const retryBtn = document.createElement('button');
1135
+ retryBtn.className = 'fe-error-btn';
1136
+ retryBtn.textContent = 'Retry';
1137
+ retryBtn.onclick = () => loadDirectory(errInfo.path || state.currentPath);
1138
+ actions.appendChild(retryBtn);
1139
+
1140
+ list.appendChild(block);
1141
+ }
1142
+
1143
+ function renderFileItem(file, depth = 0) {
1144
+ const state = getState();
1145
+ const item = document.createElement('div');
1146
+ item.className = `fe-item ${file.is_dir ? 'fe-dir' : ''}`;
1147
+ item.dataset.path = file.path || joinPath(state.currentPath, file.name);
1148
+ item.dataset.depth = depth;
1149
+
1150
+ const fileType = file.is_dir ? { icon: 'folder', color: 'var(--accent-yellow)' } : getFileType(file.name);
1151
+ const sizeText = file.is_dir ? '' : (file.size !== undefined ? formatFileSize(file.size) : '');
1152
+ const modText = formatDate(file.mtime);
1153
+ const kindText = getKindLabel(file);
1154
+ const absPath = item.dataset.path;
1155
+ const gitStatus = file.is_dir ? dirHasGitChanges(absPath) : state.gitStatus.get(absPath);
1156
+ const gitGlyph = gitStatus ? (GIT_STATUS_ICONS[gitStatus] || gitStatus) : '';
1157
+ const gitBadge = gitStatus
1158
+ ? `<span class="fe-git fe-git-${gitStatus}" data-tooltip="${GIT_STATUS_LABELS[gitStatus] || gitStatus}">${gitGlyph}</span>`
1159
+ : '';
1160
+
1161
+ if (gitStatus) {
1162
+ item.classList.add('fe-has-git-change');
1163
+ item.classList.add(`fe-git-status-${gitStatus}`);
1164
+ }
1165
+ if (state.selectedPath === absPath) item.classList.add('selected');
1166
+
1167
+ // Tree indent — reserve one slot per depth level, plus chevron for dirs / spacer for files
1168
+ const indentPx = depth * 18;
1169
+ let chevron = '<span class="fe-tree-spacer"></span>';
1170
+ if (file.is_dir) {
1171
+ const isExpanded = state.expanded.has(absPath);
1172
+ chevron = `<button class="fe-tree-chevron ${isExpanded ? 'expanded' : ''}" data-tooltip="${isExpanded ? 'Collapse' : 'Expand'}">
1173
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 6 15 12 9 18"/></svg>
1174
+ </button>`;
1175
+ }
1176
+
1177
+ item.innerHTML = `
1178
+ <span class="fe-tree-indent" style="width:${indentPx}px"></span>
1179
+ ${chevron}
1180
+ <span class="fe-icon" style="color: ${fileType.color}">${ICONS[fileType.icon] || '📄'}</span>
1181
+ <span class="fe-name">${escapeHtml(file.name)}</span>
1182
+ ${gitBadge}
1183
+ <span class="fe-kind">${escapeHtml(kindText)}</span>
1184
+ <span class="fe-modified">${modText}</span>
1185
+ <span class="fe-size">${sizeText}</span>
1186
+ `;
1187
+
1188
+ // Chevron click → toggle expand (don't navigate)
1189
+ const chevronEl = item.querySelector('.fe-tree-chevron');
1190
+ if (chevronEl) {
1191
+ chevronEl.onclick = (e) => {
1192
+ e.stopPropagation();
1193
+ toggleExpanded(absPath);
1194
+ };
1195
+ }
1196
+
1197
+ // Row click: dir toggles expansion (like the chevron), file previews.
1198
+ // Double-click on a dir enters it (navigates in). Context menu has "Enter".
1199
+ item.onclick = (e) => {
1200
+ if (chevronEl && chevronEl.contains(e.target)) return; // chevron handled above
1201
+ state.selectedPath = absPath;
1202
+ if (file.is_dir) {
1203
+ toggleExpanded(absPath); // renders internally
1204
+ return;
1205
+ }
1206
+ window.app?.previewFile(absPath, { imageGallery: 'dir' });
1207
+ renderContent();
1208
+ };
1209
+
1210
+ item.ondblclick = () => {
1211
+ if (file.is_dir) {
1212
+ loadDirectory(absPath);
1213
+ } else {
1214
+ window.app?.openFileInEditor(absPath, state.cwd);
1215
+ }
1216
+ };
1217
+
1218
+ item.oncontextmenu = (e) => {
1219
+ e.preventDefault();
1220
+ showContextMenu(e, file, absPath);
1221
+ };
1222
+
1223
+ // Long press for touch
1224
+ let pressTimer;
1225
+ item.ontouchstart = () => {
1226
+ pressTimer = setTimeout(() => {
1227
+ showContextMenu({ clientX: 100, clientY: item.getBoundingClientRect().top }, file, absPath);
1228
+ }, 500);
1229
+ };
1230
+ item.ontouchend = () => clearTimeout(pressTimer);
1231
+ item.ontouchmove = () => clearTimeout(pressTimer);
1232
+
1233
+ return item;
1234
+ }
1235
+
1236
+ async function toggleExpanded(absPath) {
1237
+ const state = getState();
1238
+ if (state.expanded.has(absPath)) {
1239
+ state.expanded.delete(absPath);
1240
+ renderContent();
1241
+ return;
1242
+ }
1243
+ state.expanded.add(absPath);
1244
+ state.childrenError.delete(absPath);
1245
+ if (!state.childrenCache.has(absPath)) {
1246
+ state.loadingChildren.add(absPath);
1247
+ renderContent();
1248
+ try {
1249
+ const children = await fetchDirectory(absPath);
1250
+ state.childrenCache.set(absPath, children);
1251
+ } catch (err) {
1252
+ state.childrenCache.set(absPath, []);
1253
+ state.childrenError.set(absPath, { status: err.status, detail: err.detail });
1254
+ } finally {
1255
+ state.loadingChildren.delete(absPath);
1256
+ }
1257
+ }
1258
+ renderContent();
1259
+ }
1260
+
1261
+ // Flat results list rendered when the inline filter triggers a recursive
1262
+ // search. Replaces the tree for the duration of the active query.
1263
+ function renderFlatMatches(container, matches) {
1264
+ const state = getState();
1265
+ const list = document.createElement('div');
1266
+ list.className = 'fe-list fe-flat-matches';
1267
+
1268
+ if (state.treeIndexLoading && matches === null) {
1269
+ list.innerHTML = `<div class="fe-loading">Scanning subtree…</div>`;
1270
+ container.appendChild(list);
1271
+ return;
1272
+ }
1273
+
1274
+ if (state.treeIndexError) {
1275
+ list.innerHTML = `<div class="fe-empty">Couldn't scan subtree: ${escapeHtml(state.treeIndexError)}</div>`;
1276
+ container.appendChild(list);
1277
+ return;
1278
+ }
1279
+
1280
+ const cwd = state.cwd || state.currentPath || '';
1281
+ const query = state.filterQuery;
1282
+
1283
+ const visible = (matches || []).filter(p => {
1284
+ const name = basename(p);
1285
+ if (!state.showHidden && name.startsWith('.')) return false;
1286
+ return matchesFilter({ name, is_dir: false }, state.filter);
1287
+ });
1288
+
1289
+ if (visible.length === 0) {
1290
+ list.innerHTML = `
1291
+ <div class="fe-empty">
1292
+ <span class="fe-empty-icon">${ICONS.search || '🔍'}</span>
1293
+ <span class="fe-empty-text">No matches for "${escapeHtml(query)}"</span>
1294
+ </div>
1295
+ `;
1296
+ container.appendChild(list);
1297
+ return;
1298
+ }
1299
+
1300
+ visible.forEach(path => {
1301
+ const item = document.createElement('div');
1302
+ item.className = 'fe-item fe-flat-match';
1303
+ item.dataset.path = path;
1304
+
1305
+ const name = basename(path);
1306
+ const fileType = getFileType(name);
1307
+ const parent = dirname(relativeTo(path, cwd));
1308
+
1309
+ if (state.selectedPath === path) item.classList.add('selected');
1310
+
1311
+ item.innerHTML = `
1312
+ <span class="fe-icon" style="color: ${fileType.color}">${ICONS[fileType.icon] || '📄'}</span>
1313
+ <div class="fe-match-info">
1314
+ <span class="fe-name">${escapeHtml(name)}</span>
1315
+ <span class="fe-match-path">${escapeHtml(parent || '.')}</span>
1316
+ </div>
1317
+ `;
1318
+
1319
+ item.onclick = () => {
1320
+ state.selectedPath = path;
1321
+ window.app?.previewFile(path, { imageGallery: 'dir' });
1322
+ renderList();
1323
+ };
1324
+ item.ondblclick = () => window.app?.openFileInEditor(path, cwd);
1325
+ item.oncontextmenu = (e) => {
1326
+ e.preventDefault();
1327
+ showContextMenu(e, { name, is_dir: false, path }, path);
1328
+ };
1329
+
1330
+ list.appendChild(item);
1331
+ });
1332
+
1333
+ if (matches.length >= FLAT_MATCH_LIMIT) {
1334
+ const more = document.createElement('div');
1335
+ more.className = 'fe-flat-more';
1336
+ more.textContent = `Showing first ${FLAT_MATCH_LIMIT} matches — narrow the query for more`;
1337
+ list.appendChild(more);
1338
+ } else if (state.treeIndexTruncated) {
1339
+ const truncated = document.createElement('div');
1340
+ truncated.className = 'fe-flat-more';
1341
+ truncated.textContent = 'Index truncated at 20,000 files — deep results may be missing';
1342
+ list.appendChild(truncated);
1343
+ }
1344
+
1345
+ container.appendChild(list);
1346
+ }
1347
+
1348
+ // ============================================================================
1349
+ // Main Render Function
1350
+ // ============================================================================
1351
+
1352
+ function renderContent() {
1353
+ const state = getState();
1354
+ const container = state.currentContainer;
1355
+ if (!container) return;
1356
+
1357
+ // Preserve scroll position across full re-renders (expand/collapse/sort
1358
+ // rebuild the DOM; without this the list jumps back to the top).
1359
+ const prevList = container.querySelector('.fe-list');
1360
+ const prevScrollTop = prevList ? prevList.scrollTop : 0;
1361
+
1362
+ container.innerHTML = '';
1363
+ container.className = 'fe-container';
1364
+
1365
+ renderToolbar(container);
1366
+
1367
+ const content = document.createElement('div');
1368
+ content.className = 'fe-content';
1369
+ renderBreadcrumb(content);
1370
+ renderFilterBar(content);
1371
+ renderFileList(content);
1372
+
1373
+ container.appendChild(content);
1374
+
1375
+ // Restore scroll on the new .fe-list after it's in the DOM
1376
+ if (prevScrollTop > 0) {
1377
+ const newList = container.querySelector('.fe-list');
1378
+ if (newList) newList.scrollTop = prevScrollTop;
1379
+ }
1380
+
1381
+ // Update widget summary
1382
+ if (state.widget) {
1383
+ const count = state.files.length;
1384
+ const path = basename(state.currentPath) || 'Files';
1385
+ state.widget.setSummary?.(`${path} (${count})`);
1386
+ }
1387
+ }
1388
+
1389
+ // Partial re-render: rebuilds only the header + list, leaving toolbar,
1390
+ // breadcrumb, and filter bar (and its focus/caret) intact. Called on every
1391
+ // filter keystroke so typing stays responsive.
1392
+ function renderList() {
1393
+ const state = getState();
1394
+ const container = state.currentContainer;
1395
+ if (!container) return;
1396
+ const content = container.querySelector('.fe-content');
1397
+ if (!content) { renderContent(); return; }
1398
+ const prevList = content.querySelector('.fe-list');
1399
+ const prevHeader = content.querySelector('.fe-header-row');
1400
+ const prevScrollTop = prevList ? prevList.scrollTop : 0;
1401
+ if (prevList) prevList.remove();
1402
+ if (prevHeader) prevHeader.remove();
1403
+ renderFileList(content);
1404
+ const newList = content.querySelector('.fe-list');
1405
+ if (newList && prevScrollTop > 0) newList.scrollTop = prevScrollTop;
1406
+ }
1407
+
1408
+ // ============================================================================
1409
+ // Actions & Helpers
1410
+ // ============================================================================
1411
+
1412
+ async function loadDirectory(path) {
1413
+ const state = getState();
1414
+ state.loading = true;
1415
+ state.error = null;
1416
+ state.errorInfo = null;
1417
+ state.navigateTo(path);
1418
+ renderContent();
1419
+
1420
+ try {
1421
+ // Fetch files and git status in parallel — git status is one-shot per cwd
1422
+ const [files] = await Promise.all([
1423
+ fetchDirectory(path),
1424
+ refreshGitStatus(false),
1425
+ ]);
1426
+ state.files = files;
1427
+ state.loading = false;
1428
+ renderContent();
1429
+ } catch (err) {
1430
+ state.loading = false;
1431
+ state.error = err.message;
1432
+ state.errorInfo = { status: err.status, detail: err.detail, path: err.path || path };
1433
+ state.files = [];
1434
+ renderContent();
1435
+ }
1436
+ }
1437
+
1438
+ // Refresh the current listing in place — re-fetches the current dir and every
1439
+ // expanded subtree plus git status, then re-renders. Preserves scroll position
1440
+ // (renderContent handles that) and expansion state (state.expanded /
1441
+ // childrenCache are not cleared). Used on window focus / widget open so
1442
+ // external file system changes (e.g. Claude wrote a new file via the terminal)
1443
+ // show up without requiring a manual refresh.
1444
+ //
1445
+ // Concurrent calls are coalesced; calls within 500ms of each other are skipped
1446
+ // unless force=true. The manual refresh button passes force.
1447
+ let _refreshing = null;
1448
+ let _lastRefreshAt = 0;
1449
+ async function refreshInPlace({ force = false } = {}) {
1450
+ const state = getState();
1451
+ if (!state.currentPath) return;
1452
+ if (_refreshing) return _refreshing;
1453
+ if (!force && Date.now() - _lastRefreshAt < 500) return;
1454
+
1455
+ _refreshing = (async () => {
1456
+ const paths = [state.currentPath, ...Array.from(state.expanded)];
1457
+ state.gitFetchedForCwd = null; // force git re-fetch
1458
+ invalidateTreeIndex(); // recursive search index gets rebuilt on next query
1459
+ // We catch into a shape that preserves the error so we can surface it;
1460
+ // a plain null would lose 403 vs 404.
1461
+ const results = await Promise.all([
1462
+ ...paths.map(p => fetchDirectory(p).then(
1463
+ files => ({ ok: true, files }),
1464
+ err => ({ ok: false, err }),
1465
+ )),
1466
+ refreshGitStatus(true),
1467
+ ]);
1468
+ const topResult = results[0];
1469
+ if (topResult.ok) {
1470
+ state.files = topResult.files;
1471
+ state.errorInfo = null;
1472
+ } else {
1473
+ // The top-level dir itself became inaccessible (perms changed,
1474
+ // deleted, etc.). Show it to the user instead of silently keeping
1475
+ // stale data.
1476
+ state.errorInfo = {
1477
+ status: topResult.err.status,
1478
+ detail: topResult.err.detail,
1479
+ path: state.currentPath,
1480
+ };
1481
+ state.files = [];
1482
+ }
1483
+ for (let i = 1; i < paths.length; i++) {
1484
+ const r = results[i];
1485
+ if (r.ok) {
1486
+ state.childrenCache.set(paths[i], r.files);
1487
+ state.childrenError.delete(paths[i]);
1488
+ } else {
1489
+ // Fetch failed for an expanded subtree — drop from cache and
1490
+ // collapse so stale rows don't linger.
1491
+ state.childrenCache.delete(paths[i]);
1492
+ state.expanded.delete(paths[i]);
1493
+ state.childrenError.delete(paths[i]);
1494
+ }
1495
+ }
1496
+ renderContent();
1497
+ })();
1498
+
1499
+ try { await _refreshing; }
1500
+ finally {
1501
+ _refreshing = null;
1502
+ _lastRefreshAt = Date.now();
1503
+ }
1504
+ }
1505
+
1506
+ function showContextMenu(e, file, path) {
1507
+ const state = getState();
1508
+ // Remove existing context menu
1509
+ document.querySelectorAll('.fe-context-menu').forEach(m => m.remove());
1510
+
1511
+ const menu = document.createElement('div');
1512
+ menu.className = 'fe-context-menu';
1513
+ menu.style.left = `${e.clientX}px`;
1514
+ menu.style.top = `${e.clientY}px`;
1515
+ menu.tabIndex = -1;
1516
+
1517
+ const actions = file.is_dir ? [
1518
+ { label: S.widgets.file_explorer.context_menu.enter, icon: 'folder', action: () => loadDirectory(path) },
1519
+ { label: S.widgets.file_explorer.context_menu.copy_path, icon: 'copy', action: () => { navigator.clipboard.writeText(path); showToast(S.toast.copied); } },
1520
+ { label: S.widgets.file_explorer.context_menu.open_terminal, icon: 'terminal', action: () => openInTerminal(path) }
1521
+ ] : [
1522
+ { label: S.widgets.file_explorer.context_menu.open_editor, icon: 'edit', action: () => window.app?.openFileInEditor(path, state.cwd) },
1523
+ { label: S.widgets.file_explorer.context_menu.preview, icon: 'eye', action: () => window.app?.previewFile(path, { imageGallery: 'dir' }) },
1524
+ // Only for types /api/browser/render serves with a real MIME — anything
1525
+ // else downloads instead of rendering inside the browser widget's iframe
1526
+ ...(BROWSER_RENDERABLE.test(file.name) ? [
1527
+ { label: S.widgets.file_explorer.context_menu.open_browser, icon: 'globe', action: () => BrowserWidget.navigate(path) }
1528
+ ] : []),
1529
+ { label: S.widgets.file_explorer.context_menu.copy_path, icon: 'copy', action: () => { navigator.clipboard.writeText(path); showToast(S.toast.copied); } },
1530
+ { label: S.widgets.file_explorer.context_menu.insert_chat, icon: 'message', action: () => insertToChat(`\`${path}\``) },
1531
+ { label: S.widgets.file_explorer.context_menu.copy_content, icon: 'clipboard', action: () => copyFileContent(path) },
1532
+ // Compare wizard
1533
+ { label: S.widgets.file_explorer.context_menu.compare, icon: 'columns', action: () => window.DiffViewerWidget?.openCompareWizard(path, state.cwd) }
1534
+ ];
1535
+
1536
+ const itemEls = [];
1537
+ let selectedIndex = 0;
1538
+
1539
+ const updateSelection = () => {
1540
+ itemEls.forEach((el, i) => {
1541
+ el.classList.toggle('selected', i === selectedIndex);
1542
+ });
1543
+ itemEls[selectedIndex]?.scrollIntoView({ block: 'nearest' });
1544
+ };
1545
+
1546
+ actions.forEach(({ label, icon, action }, i) => {
1547
+ const item = document.createElement('div');
1548
+ item.className = 'fe-context-item';
1549
+ item.innerHTML = `<span class="fe-context-icon">${ICONS[icon] || ''}</span>${label}`;
1550
+ item.onclick = () => {
1551
+ closeOwnContextMenu();
1552
+ action();
1553
+ };
1554
+ item.onmouseenter = () => {
1555
+ selectedIndex = i;
1556
+ updateSelection();
1557
+ };
1558
+ itemEls.push(item);
1559
+ menu.appendChild(item);
1560
+ });
1561
+
1562
+ document.body.appendChild(menu);
1563
+ // Grab focus so the widget's own keydown listener returns early — type-ahead
1564
+ // and arrow nav on the list shouldn't fire while the menu is open.
1565
+ menu.focus({ preventScroll: true });
1566
+ updateSelection();
1567
+
1568
+ // Keyboard navigation for the menu itself. Listening in the capture phase
1569
+ // ensures we beat both the widget's document-level keydown handler and the
1570
+ // global ShortcutManager for these keys while the menu is open.
1571
+ const handleMenuKey = (e) => {
1572
+ if (e.key === 'ArrowDown') {
1573
+ e.preventDefault();
1574
+ e.stopPropagation();
1575
+ selectedIndex = (selectedIndex + 1) % itemEls.length;
1576
+ updateSelection();
1577
+ } else if (e.key === 'ArrowUp') {
1578
+ e.preventDefault();
1579
+ e.stopPropagation();
1580
+ selectedIndex = (selectedIndex - 1 + itemEls.length) % itemEls.length;
1581
+ updateSelection();
1582
+ } else if (e.key === 'Home') {
1583
+ e.preventDefault();
1584
+ e.stopPropagation();
1585
+ selectedIndex = 0;
1586
+ updateSelection();
1587
+ } else if (e.key === 'End') {
1588
+ e.preventDefault();
1589
+ e.stopPropagation();
1590
+ selectedIndex = itemEls.length - 1;
1591
+ updateSelection();
1592
+ } else if (e.key === 'Enter') {
1593
+ e.preventDefault();
1594
+ e.stopPropagation();
1595
+ itemEls[selectedIndex]?.click();
1596
+ }
1597
+ };
1598
+ document.addEventListener('keydown', handleMenuKey, true);
1599
+
1600
+ // Close on click outside
1601
+ const closeMenu = (e) => {
1602
+ if (!menu.contains(e.target)) {
1603
+ closeOwnContextMenu();
1604
+ }
1605
+ };
1606
+ const cleanup = () => {
1607
+ document.removeEventListener('click', closeMenu);
1608
+ document.removeEventListener('keydown', handleMenuKey, true);
1609
+ menu.__cleanup = null;
1610
+ };
1611
+ menu.__cleanup = cleanup;
1612
+ setTimeout(() => document.addEventListener('click', closeMenu), 0);
1613
+ }
1614
+
1615
+ // Alt+Enter on a selected item: open the same context menu the right-click
1616
+ // gesture would, anchored to the item's rect (since we have no mouse event).
1617
+ function openContextMenuForSelection() {
1618
+ const state = getState();
1619
+ const items = getVisibleItems();
1620
+ if (items.length === 0) return;
1621
+ const el = items.find(i => i.dataset.path === state.selectedPath) || items[0];
1622
+ if (!state.selectedPath) setSelection(el.dataset.path, false);
1623
+ const rect = el.getBoundingClientRect();
1624
+ const pseudoEvent = {
1625
+ clientX: rect.left + 24,
1626
+ clientY: rect.bottom,
1627
+ preventDefault() {}
1628
+ };
1629
+ const absPath = el.dataset.path;
1630
+ const file = { name: basename(absPath), is_dir: el.classList.contains('fe-dir'), path: absPath };
1631
+ showContextMenu(pseudoEvent, file, absPath);
1632
+ }
1633
+
1634
+ // Close the file-explorer-owned context menu. Returns true if one was found.
1635
+ // Restores focus to the widget so arrow keys keep working after the menu closes.
1636
+ function closeOwnContextMenu() {
1637
+ const menu = document.querySelector('.fe-context-menu');
1638
+ if (!menu) return false;
1639
+ menu.__cleanup?.();
1640
+ menu.remove();
1641
+ const state = getState();
1642
+ state.currentContainer?.focus({ preventScroll: true });
1643
+ return true;
1644
+ }
1645
+
1646
+ async function copyFileContent(path) {
1647
+ try {
1648
+ const content = await fetchFileContent(path);
1649
+ await navigator.clipboard.writeText(content);
1650
+ showToast(S.toast.content_copied);
1651
+ } catch (err) {
1652
+ showToast(S.errors.copy_failed);
1653
+ }
1654
+ }
1655
+
1656
+ function insertToChat(text) {
1657
+ const app = window.app;
1658
+ const input = app?.els?.messageInput;
1659
+ if (!input) return;
1660
+
1661
+ // In full-tab mode the chat input is hidden — jump back to the session view
1662
+ if (app.tabCtrl?.activeWidgetTabId && app.tabCtrl.activeSession) {
1663
+ app.tabCtrl.switchToSession(app.tabCtrl.activeSession);
1664
+ }
1665
+
1666
+ // Insert at cursor, padding with spaces so paths don't glue to existing text
1667
+ const start = input.selectionStart ?? input.value.length;
1668
+ const end = input.selectionEnd ?? start;
1669
+ const before = input.value.slice(0, start);
1670
+ const after = input.value.slice(end);
1671
+ const needsSpace = before.length > 0 && !/\s$/.test(before);
1672
+ const insert = (needsSpace ? ' ' : '') + text + ' ';
1673
+ input.value = before + insert + after;
1674
+ const pos = before.length + insert.length;
1675
+ input.setSelectionRange(pos, pos);
1676
+ input.focus();
1677
+ app.autoResizeInput?.();
1678
+ }
1679
+
1680
+ function openInTerminal(path) {
1681
+ // New terminal tab already cd'd into the directory
1682
+ window.app?.tabCtrl?.openTerminalWidgetTab({ cwd: path });
1683
+ }
1684
+
1685
+ function showToast(message) {
1686
+ // Simple toast notification
1687
+ const toast = document.createElement('div');
1688
+ toast.className = 'fe-toast';
1689
+ toast.textContent = message;
1690
+ document.body.appendChild(toast);
1691
+ setTimeout(() => toast.remove(), 2000);
1692
+ }
1693
+
1694
+ // ============================================================================
1695
+ // Utilities
1696
+ // ============================================================================
1697
+
1698
+ // ============================================================================
1699
+ // Widget Registration
1700
+ // ============================================================================
1701
+
1702
+ export function registerFileExplorerWidget() {
1703
+ WidgetManager.register('file-explorer', {
1704
+ type: 'top-sheet',
1705
+ title: S.widgets.titles.files,
1706
+ icon: 'folder',
1707
+ shortcut: 'Alt+F',
1708
+
1709
+ deviceTypes: {
1710
+ default: 'top-sheet',
1711
+ phone: 'top-sheet',
1712
+ tablet: 'top-sheet',
1713
+ desktop: 'floating'
1714
+ },
1715
+
1716
+ heights: { half: '45vh', full: '85vh' },
1717
+ width: '320px',
1718
+ minWidth: '280px',
1719
+ maxWidth: '500px',
1720
+
1721
+ sessionAware: true,
1722
+ persistState: true,
1723
+ allowTransform: true,
1724
+ allowedTypes: ['top-sheet', 'bottom-sheet', 'sidebar-left', 'sidebar-right', 'floating', 'tab'],
1725
+
1726
+ headerActions: [
1727
+ {
1728
+ icon: 'home',
1729
+ title: S.widgets.header_actions.go_home,
1730
+ onClick: () => {
1731
+ const state = getState();
1732
+ loadDirectory(state.cwd || '/');
1733
+ }
1734
+ },
1735
+ {
1736
+ icon: 'search',
1737
+ title: S.widgets.header_actions.search_files,
1738
+ onClick: () => {
1739
+ openFilterBar();
1740
+ }
1741
+ }
1742
+ ],
1743
+
1744
+ render: (container, context) => {
1745
+ const state = getState(context.sessionId);
1746
+ state.currentContainer = container;
1747
+ state.widget = WidgetManager.get('file-explorer');
1748
+
1749
+ // Focusable so type-ahead / Ctrl+F can target the widget without
1750
+ // the user having to click into it first.
1751
+ container.tabIndex = -1;
1752
+
1753
+ // Update CWD if session context changed
1754
+ if (context.cwd && context.cwd !== state.cwd) {
1755
+ state.cwd = context.cwd;
1756
+ invalidateTreeIndex();
1757
+ }
1758
+
1759
+ // Determine if we need to load
1760
+ const needsLoad = !state.loading && (
1761
+ // No path set - use session CWD
1762
+ (!state.currentPath && state.cwd) ||
1763
+ // Path is set but no files loaded
1764
+ (state.currentPath && state.files.length === 0)
1765
+ );
1766
+
1767
+ if (needsLoad) {
1768
+ const pathToLoad = state.currentPath || state.cwd;
1769
+ if (pathToLoad) {
1770
+ state.currentPath = pathToLoad;
1771
+ loadDirectory(pathToLoad);
1772
+ } else {
1773
+ renderContent();
1774
+ }
1775
+ } else {
1776
+ renderContent();
1777
+ }
1778
+ },
1779
+
1780
+ onOpen: () => {
1781
+ const state = getState();
1782
+ if (!state.currentPath && state.cwd) {
1783
+ loadDirectory(state.cwd);
1784
+ } else if (state.currentPath) {
1785
+ // Re-opened after being closed: pull latest listing so files
1786
+ // created/deleted while the widget was hidden show up.
1787
+ refreshInPlace();
1788
+ }
1789
+ // Move focus into the widget so keyboard shortcuts (Ctrl+F) and
1790
+ // type-ahead target it instead of the underlying chat input.
1791
+ if (state.currentContainer) {
1792
+ state.currentContainer.focus({ preventScroll: true });
1793
+ }
1794
+ },
1795
+
1796
+ onRefresh: () => refreshInPlace({ force: true }),
1797
+
1798
+ onDestroy: (sessionId) => {
1799
+ destroyState(sessionId);
1800
+ }
1801
+ });
1802
+
1803
+ // Set up event subscriptions
1804
+ setupEventSubscriptions();
1805
+ setupKeyboardNavigation();
1806
+ }
1807
+
1808
+ // ============================================================================
1809
+ // Event Bus Subscriptions
1810
+ // ============================================================================
1811
+
1812
+ function setupEventSubscriptions() {
1813
+ // Listen for file open requests from other widgets
1814
+ WidgetBus.on('navigate:directory', ({ path }) => {
1815
+ loadDirectory(path);
1816
+ WidgetManager.open('file-explorer');
1817
+ });
1818
+
1819
+ // Listen for chat insert requests from other widgets
1820
+ WidgetBus.on('chat:insert', ({ text }) => insertToChat(text));
1821
+
1822
+ // When the file preview closes, hand focus back to the explorer so the
1823
+ // user can keep arrow-navigating — otherwise the preview's Escape leaves
1824
+ // focus on body, which breaks our keydown listener.
1825
+ WidgetBus.on('widget:closed', ({ widgetId }) => {
1826
+ if (widgetId !== 'file-preview') return;
1827
+ const widget = WidgetManager.get('file-explorer');
1828
+ if (!widget?.isVisible) return;
1829
+ const state = getState();
1830
+ state.currentContainer?.focus({ preventScroll: true });
1831
+ });
1832
+
1833
+ // Smart refresh: when the user switches back to the app (focus or
1834
+ // visibilitychange), pull a fresh listing. Catches the "I just created a
1835
+ // file in the terminal and swapped back" case without requiring a manual
1836
+ // click. Debounce inside refreshInPlace prevents spam.
1837
+ const refreshIfVisible = () => {
1838
+ const widget = WidgetManager.get('file-explorer');
1839
+ if (widget?.isVisible) refreshInPlace();
1840
+ };
1841
+ window.addEventListener('focus', refreshIfVisible);
1842
+ document.addEventListener('visibilitychange', () => {
1843
+ if (document.visibilityState === 'visible') refreshIfVisible();
1844
+ });
1845
+ }
1846
+
1847
+ // ============================================================================
1848
+ // Keyboard Navigation
1849
+ // ============================================================================
1850
+
1851
+ // Flat list of currently visible (rendered) items with a resolvable path.
1852
+ // Skips the ".." parent row and placeholder rows (loading/empty).
1853
+ function getVisibleItems() {
1854
+ const state = getState();
1855
+ const container = state.currentContainer;
1856
+ if (!container) return [];
1857
+ return Array.from(container.querySelectorAll('.fe-list .fe-item[data-path]'));
1858
+ }
1859
+
1860
+ // Move the keyboard-selection highlight to `path`. Updates DOM directly so we
1861
+ // don't pay for a full re-render on every arrow keypress.
1862
+ function setSelection(path, scroll = true) {
1863
+ const state = getState();
1864
+ state.selectedPath = path;
1865
+ const container = state.currentContainer;
1866
+ if (!container) return;
1867
+ container.querySelectorAll('.fe-item.selected').forEach(i => i.classList.remove('selected'));
1868
+ if (!path) return;
1869
+ const el = container.querySelector(`.fe-item[data-path="${CSS.escape(path)}"]`);
1870
+ if (el) {
1871
+ el.classList.add('selected');
1872
+ if (scroll) el.scrollIntoView({ block: 'nearest' });
1873
+ }
1874
+ }
1875
+
1876
+ // ArrowUp/Down traversal across visible items. ArrowUp past the first item
1877
+ // returns focus to the filter input (if open), so ArrowDown-into-tree-and-back
1878
+ // feels symmetric.
1879
+ function navigateSelection(delta) {
1880
+ const state = getState();
1881
+ const items = getVisibleItems();
1882
+ if (items.length === 0) return;
1883
+
1884
+ let idx = items.findIndex(el => el.dataset.path === state.selectedPath);
1885
+ if (idx < 0) idx = delta > 0 ? -1 : items.length;
1886
+ const nextIdx = idx + delta;
1887
+
1888
+ if (nextIdx < 0 && state.filterBarOpen) {
1889
+ const input = state.currentContainer?.querySelector('.fe-filter-input');
1890
+ if (input) {
1891
+ setSelection(null);
1892
+ input.focus();
1893
+ input.setSelectionRange(input.value.length, input.value.length);
1894
+ return;
1895
+ }
1896
+ }
1897
+
1898
+ const clamped = Math.max(0, Math.min(items.length - 1, nextIdx));
1899
+ setSelection(items[clamped].dataset.path);
1900
+ }
1901
+
1902
+ // Enter: on a dir, toggle expansion; on a file, preview.
1903
+ function activateSelection() {
1904
+ const state = getState();
1905
+ const items = getVisibleItems();
1906
+ if (items.length === 0) return;
1907
+ let el = items.find(i => i.dataset.path === state.selectedPath);
1908
+ if (!el) {
1909
+ el = items[0];
1910
+ setSelection(el.dataset.path);
1911
+ }
1912
+ const absPath = el.dataset.path;
1913
+ if (el.classList.contains('fe-dir')) {
1914
+ toggleExpanded(absPath);
1915
+ } else {
1916
+ window.app?.previewFile(absPath, { imageGallery: 'dir' });
1917
+ }
1918
+ }
1919
+
1920
+ // ArrowRight: expand a collapsed dir; on an expanded dir, step into first child.
1921
+ function handleArrowRight() {
1922
+ const state = getState();
1923
+ if (!state.selectedPath) return;
1924
+ const container = state.currentContainer;
1925
+ const el = container?.querySelector(`.fe-item[data-path="${CSS.escape(state.selectedPath)}"]`);
1926
+ if (!el || !el.classList.contains('fe-dir')) return;
1927
+ if (!state.expanded.has(state.selectedPath)) {
1928
+ toggleExpanded(state.selectedPath);
1929
+ } else {
1930
+ navigateSelection(1);
1931
+ }
1932
+ }
1933
+
1934
+ // ArrowLeft: collapse expanded dir; otherwise jump to parent dir row.
1935
+ function handleArrowLeft() {
1936
+ const state = getState();
1937
+ if (!state.selectedPath) return;
1938
+ const container = state.currentContainer;
1939
+ const el = container?.querySelector(`.fe-item[data-path="${CSS.escape(state.selectedPath)}"]`);
1940
+ if (!el) return;
1941
+ const isDir = el.classList.contains('fe-dir');
1942
+ if (isDir && state.expanded.has(state.selectedPath)) {
1943
+ toggleExpanded(state.selectedPath);
1944
+ return;
1945
+ }
1946
+ const parent = parentOf(state.selectedPath);
1947
+ if (!parent) return;
1948
+ const parentEl = container?.querySelector(`.fe-item[data-path="${CSS.escape(parent)}"]`);
1949
+ if (parentEl) setSelection(parent);
1950
+ }
1951
+
1952
+ function setupKeyboardNavigation() {
1953
+ // Note: Ctrl/Cmd+F and Escape are handled by app.openSearch() and
1954
+ // app.handleEscape() — the global ShortcutManager intercepts those keys in
1955
+ // the capture phase with stopImmediatePropagation, so we can't catch them
1956
+ // here. Type-ahead (printable keys) and list nav (arrows, Backspace, Enter)
1957
+ // aren't registered shortcuts, so they fall through to this bubble listener.
1958
+ document.addEventListener('keydown', (e) => {
1959
+ const widget = WidgetManager.get('file-explorer');
1960
+ if (!widget?.isVisible) return;
1961
+
1962
+ const state = getState();
1963
+ const container = state.currentContainer;
1964
+ if (!container?.contains(document.activeElement) && document.activeElement !== document.body) return;
1965
+
1966
+ const inInput = document.activeElement?.matches('input, textarea');
1967
+
1968
+ // Type-ahead: printable char in list view opens the inline filter bar
1969
+ // and pre-populates it. '/' opens the bar empty (classic "start search").
1970
+ // If the filter bar is already open (but focus is on the tree), append
1971
+ // to the existing query and return focus to the input.
1972
+ const isPrintable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
1973
+ if (isPrintable && !inInput) {
1974
+ e.preventDefault();
1975
+ if (state.filterBarOpen) {
1976
+ const input = container.querySelector('.fe-filter-input');
1977
+ if (input) {
1978
+ state.filterQuery = (state.filterQuery || '') + e.key;
1979
+ input.value = state.filterQuery;
1980
+ input.focus();
1981
+ input.setSelectionRange(input.value.length, input.value.length);
1982
+ renderList();
1983
+ }
1984
+ } else {
1985
+ openFilterBar(e.key === '/' ? '' : e.key);
1986
+ }
1987
+ return;
1988
+ }
1989
+
1990
+ // Filter input owns its own ArrowUp/Down/Enter (see renderFilterBar).
1991
+ if (inInput) return;
1992
+
1993
+ switch (e.key) {
1994
+ case 'ArrowUp':
1995
+ e.preventDefault();
1996
+ navigateSelection(-1);
1997
+ break;
1998
+ case 'ArrowDown':
1999
+ e.preventDefault();
2000
+ navigateSelection(1);
2001
+ break;
2002
+ case 'ArrowRight':
2003
+ e.preventDefault();
2004
+ handleArrowRight();
2005
+ break;
2006
+ case 'ArrowLeft':
2007
+ e.preventDefault();
2008
+ handleArrowLeft();
2009
+ break;
2010
+ case 'Enter':
2011
+ e.preventDefault();
2012
+ if (e.altKey) {
2013
+ openContextMenuForSelection();
2014
+ } else {
2015
+ activateSelection();
2016
+ }
2017
+ break;
2018
+ case 'Backspace': {
2019
+ e.preventDefault();
2020
+ // If filtering, Backspace pops the last char (rather than
2021
+ // navigating up a directory — which is surprising mid-filter).
2022
+ if (state.filterBarOpen && state.filterQuery) {
2023
+ state.filterQuery = state.filterQuery.slice(0, -1);
2024
+ const input = container.querySelector('.fe-filter-input');
2025
+ if (input) input.value = state.filterQuery;
2026
+ renderList();
2027
+ break;
2028
+ }
2029
+ const parentPath = parentOf(state.currentPath) || pathRoot(state.currentPath) || '/';
2030
+ if (parentPath !== state.currentPath) {
2031
+ loadDirectory(parentPath);
2032
+ }
2033
+ break;
2034
+ }
2035
+ }
2036
+ });
2037
+ }
2038
+
2039
+ // ============================================================================
2040
+ // External API (for backward compatibility with app.js)
2041
+ // ============================================================================
2042
+
2043
+ /**
2044
+ * FileExplorerWidget API - provides methods for external control
2045
+ */
2046
+ export const FileExplorerWidget = {
2047
+ /** Open the file explorer */
2048
+ open() {
2049
+ WidgetManager.open('file-explorer');
2050
+ },
2051
+
2052
+ /** Close the file explorer */
2053
+ close() {
2054
+ WidgetManager.close('file-explorer');
2055
+ },
2056
+
2057
+ /** Toggle the file explorer */
2058
+ toggle() {
2059
+ WidgetManager.toggle('file-explorer');
2060
+ },
2061
+
2062
+ /** Get current state */
2063
+ get state() {
2064
+ const widget = WidgetManager.get('file-explorer');
2065
+ return widget?.state || 'collapsed';
2066
+ },
2067
+
2068
+ /** Check if visible */
2069
+ get isVisible() {
2070
+ const widget = WidgetManager.get('file-explorer');
2071
+ return widget?.isVisible || false;
2072
+ },
2073
+
2074
+ /** Is focus inside the widget (or on body while widget is open)? */
2075
+ isFocused() {
2076
+ const widget = WidgetManager.get('file-explorer');
2077
+ if (!widget?.isVisible) return false;
2078
+ const state = getState();
2079
+ const container = state.currentContainer;
2080
+ if (!container) return false;
2081
+ return container.contains(document.activeElement) || document.activeElement === document.body;
2082
+ },
2083
+
2084
+ /** Open the inline filter bar and focus its input. Returns true if applied. */
2085
+ openSearch() {
2086
+ const widget = WidgetManager.get('file-explorer');
2087
+ if (!widget?.isVisible) return false;
2088
+ openFilterBar();
2089
+ return true;
2090
+ },
2091
+
2092
+ /**
2093
+ * Escape handler. Priority (highest first):
2094
+ * 1. Close the file-explorer context menu if open
2095
+ * 2. Cancel the breadcrumb path editor if active
2096
+ * 3. Close the inline filter bar if open
2097
+ * Returns true if any of these was handled.
2098
+ */
2099
+ handleEscape() {
2100
+ const widget = WidgetManager.get('file-explorer');
2101
+ if (!widget?.isVisible) return false;
2102
+ if (closeOwnContextMenu()) return true;
2103
+ const state = getState();
2104
+ if (state.pathEditing) { cancelPathEdit(); return true; }
2105
+ if (!state.filterBarOpen) return false;
2106
+ closeFilterBar();
2107
+ return true;
2108
+ },
2109
+
2110
+ /** Set home path (CWD) */
2111
+ setHomePath(path) {
2112
+ const state = getState();
2113
+ state.cwd = path;
2114
+ if (!state.currentPath) {
2115
+ state.currentPath = path;
2116
+ }
2117
+ },
2118
+
2119
+ /** Navigate to directory */
2120
+ navigateTo(path) {
2121
+ loadDirectory(path);
2122
+ WidgetManager.open('file-explorer');
2123
+ },
2124
+
2125
+ /**
2126
+ * Reveal a file: open the explorer at its parent directory, select the
2127
+ * row, scroll it into view, and flash it. PWA-friendly alternative to a
2128
+ * host-OS file manager — works on iPad and headless servers alike.
2129
+ */
2130
+ async revealFile(path) {
2131
+ if (!path) return;
2132
+ const parentDir = parentOf(path) || pathRoot(path) || '/';
2133
+
2134
+ // Open the widget first so state.currentContainer is set before
2135
+ // loadDirectory's renderContent runs. Wait one frame for the render
2136
+ // callback to commit the container reference.
2137
+ WidgetManager.open('file-explorer');
2138
+ await new Promise(r => requestAnimationFrame(r));
2139
+
2140
+ await loadDirectory(parentDir);
2141
+
2142
+ // Wait one more frame so the row DOM is in place before selecting.
2143
+ await new Promise(r => requestAnimationFrame(r));
2144
+
2145
+ setSelection(path, true);
2146
+ const container = getState().currentContainer;
2147
+ const el = container?.querySelector(`.fe-item[data-path="${CSS.escape(path)}"]`);
2148
+ if (el) {
2149
+ el.classList.add('flash');
2150
+ setTimeout(() => el.classList.remove('flash'), 1500);
2151
+ }
2152
+ },
2153
+
2154
+ /** Force an in-place refresh (re-fetches current dir + expanded subtrees + git) */
2155
+ refresh() {
2156
+ return refreshInPlace({ force: true });
2157
+ },
2158
+
2159
+ /** Get internal state for debugging */
2160
+ getState() {
2161
+ return getState();
2162
+ },
2163
+
2164
+ /** Destroy state for a session */
2165
+ destroyState
2166
+ };