@myrialabs/clopen 0.0.1

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 (456) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/release.yml +60 -0
  3. package/.github/workflows/test.yml +40 -0
  4. package/CONTRIBUTING.md +499 -0
  5. package/LICENSE +21 -0
  6. package/README.md +209 -0
  7. package/backend/index.ts +156 -0
  8. package/backend/lib/chat/helpers.ts +42 -0
  9. package/backend/lib/chat/index.ts +2 -0
  10. package/backend/lib/chat/stream-manager.ts +1126 -0
  11. package/backend/lib/database/README.md +77 -0
  12. package/backend/lib/database/index.ts +119 -0
  13. package/backend/lib/database/migrations/001_create_projects_table.ts +31 -0
  14. package/backend/lib/database/migrations/002_create_chat_sessions_table.ts +33 -0
  15. package/backend/lib/database/migrations/003_create_messages_table.ts +32 -0
  16. package/backend/lib/database/migrations/004_create_prompt_templates_table.ts +34 -0
  17. package/backend/lib/database/migrations/005_create_settings_table.ts +24 -0
  18. package/backend/lib/database/migrations/006_add_user_to_messages.ts +58 -0
  19. package/backend/lib/database/migrations/007_create_stream_states_table.ts +41 -0
  20. package/backend/lib/database/migrations/008_create_message_snapshots_table.ts +62 -0
  21. package/backend/lib/database/migrations/009_add_delta_snapshot_fields.ts +41 -0
  22. package/backend/lib/database/migrations/010_add_soft_delete_and_branch_support.ts +70 -0
  23. package/backend/lib/database/migrations/011_git_like_commit_graph.ts +156 -0
  24. package/backend/lib/database/migrations/012_add_file_change_statistics.ts +41 -0
  25. package/backend/lib/database/migrations/013_checkpoint_tree_state.ts +118 -0
  26. package/backend/lib/database/migrations/014_add_engine_to_sessions.ts +18 -0
  27. package/backend/lib/database/migrations/015_add_model_to_sessions.ts +18 -0
  28. package/backend/lib/database/migrations/016_create_user_projects_table.ts +34 -0
  29. package/backend/lib/database/migrations/017_add_current_session_to_user_projects.ts +32 -0
  30. package/backend/lib/database/migrations/018_create_claude_accounts_table.ts +24 -0
  31. package/backend/lib/database/migrations/019_add_claude_account_to_sessions.ts +18 -0
  32. package/backend/lib/database/migrations/020_add_snapshot_tree_hash.ts +32 -0
  33. package/backend/lib/database/migrations/021_drop_prompt_templates_table.ts +33 -0
  34. package/backend/lib/database/migrations/index.ts +154 -0
  35. package/backend/lib/database/queries/checkpoint-queries.ts +87 -0
  36. package/backend/lib/database/queries/engine-queries.ts +75 -0
  37. package/backend/lib/database/queries/index.ts +9 -0
  38. package/backend/lib/database/queries/message-queries.ts +472 -0
  39. package/backend/lib/database/queries/project-queries.ts +117 -0
  40. package/backend/lib/database/queries/session-queries.ts +271 -0
  41. package/backend/lib/database/queries/settings-queries.ts +34 -0
  42. package/backend/lib/database/queries/snapshot-queries.ts +326 -0
  43. package/backend/lib/database/queries/utils-queries.ts +59 -0
  44. package/backend/lib/database/seeders/index.ts +13 -0
  45. package/backend/lib/database/seeders/settings_seeder.ts +84 -0
  46. package/backend/lib/database/utils/connection.ts +174 -0
  47. package/backend/lib/database/utils/index.ts +4 -0
  48. package/backend/lib/database/utils/migration-runner.ts +118 -0
  49. package/backend/lib/database/utils/seeder-runner.ts +121 -0
  50. package/backend/lib/engine/adapters/claude/environment.ts +164 -0
  51. package/backend/lib/engine/adapters/claude/error-handler.ts +60 -0
  52. package/backend/lib/engine/adapters/claude/index.ts +1 -0
  53. package/backend/lib/engine/adapters/claude/path-utils.ts +38 -0
  54. package/backend/lib/engine/adapters/claude/stream.ts +177 -0
  55. package/backend/lib/engine/adapters/opencode/index.ts +2 -0
  56. package/backend/lib/engine/adapters/opencode/message-converter.ts +862 -0
  57. package/backend/lib/engine/adapters/opencode/server.ts +104 -0
  58. package/backend/lib/engine/adapters/opencode/stream.ts +755 -0
  59. package/backend/lib/engine/index.ts +196 -0
  60. package/backend/lib/engine/types.ts +58 -0
  61. package/backend/lib/files/file-operations.ts +478 -0
  62. package/backend/lib/files/file-reading.ts +308 -0
  63. package/backend/lib/files/file-watcher.ts +383 -0
  64. package/backend/lib/files/path-browsing.ts +382 -0
  65. package/backend/lib/git/git-executor.ts +88 -0
  66. package/backend/lib/git/git-parser.ts +411 -0
  67. package/backend/lib/git/git-service.ts +505 -0
  68. package/backend/lib/mcp/README.md +1144 -0
  69. package/backend/lib/mcp/config.ts +316 -0
  70. package/backend/lib/mcp/index.ts +35 -0
  71. package/backend/lib/mcp/project-context.ts +236 -0
  72. package/backend/lib/mcp/servers/browser-automation/actions.ts +156 -0
  73. package/backend/lib/mcp/servers/browser-automation/browser.ts +419 -0
  74. package/backend/lib/mcp/servers/browser-automation/index.ts +791 -0
  75. package/backend/lib/mcp/servers/browser-automation/inspection.ts +501 -0
  76. package/backend/lib/mcp/servers/helper.ts +143 -0
  77. package/backend/lib/mcp/servers/index.ts +45 -0
  78. package/backend/lib/mcp/servers/weather/get-temperature.ts +56 -0
  79. package/backend/lib/mcp/servers/weather/index.ts +31 -0
  80. package/backend/lib/mcp/stdio-server.ts +103 -0
  81. package/backend/lib/mcp/types.ts +65 -0
  82. package/backend/lib/preview/browser/browser-audio-capture.ts +86 -0
  83. package/backend/lib/preview/browser/browser-console-manager.ts +263 -0
  84. package/backend/lib/preview/browser/browser-dialog-handler.ts +222 -0
  85. package/backend/lib/preview/browser/browser-interaction-handler.ts +421 -0
  86. package/backend/lib/preview/browser/browser-mcp-control.ts +415 -0
  87. package/backend/lib/preview/browser/browser-native-ui-handler.ts +512 -0
  88. package/backend/lib/preview/browser/browser-navigation-tracker.ts +104 -0
  89. package/backend/lib/preview/browser/browser-pool.ts +357 -0
  90. package/backend/lib/preview/browser/browser-preview-service.ts +882 -0
  91. package/backend/lib/preview/browser/browser-tab-manager.ts +935 -0
  92. package/backend/lib/preview/browser/browser-video-capture.ts +695 -0
  93. package/backend/lib/preview/browser/scripts/audio-stream.ts +292 -0
  94. package/backend/lib/preview/browser/scripts/cursor-tracking.ts +85 -0
  95. package/backend/lib/preview/browser/scripts/video-stream.ts +438 -0
  96. package/backend/lib/preview/browser/types.ts +359 -0
  97. package/backend/lib/preview/index.ts +24 -0
  98. package/backend/lib/project/index.ts +2 -0
  99. package/backend/lib/project/status-manager.ts +182 -0
  100. package/backend/lib/shared/index.ts +2 -0
  101. package/backend/lib/shared/port-utils.ts +25 -0
  102. package/backend/lib/shared/process-manager.ts +281 -0
  103. package/backend/lib/snapshot/blob-store.ts +227 -0
  104. package/backend/lib/snapshot/gitignore.ts +307 -0
  105. package/backend/lib/snapshot/helpers.ts +397 -0
  106. package/backend/lib/snapshot/snapshot-service.ts +483 -0
  107. package/backend/lib/terminal/helpers.ts +14 -0
  108. package/backend/lib/terminal/index.ts +8 -0
  109. package/backend/lib/terminal/pty-manager.ts +4 -0
  110. package/backend/lib/terminal/pty-session-manager.ts +387 -0
  111. package/backend/lib/terminal/shell-utils.ts +313 -0
  112. package/backend/lib/terminal/stream-manager.ts +293 -0
  113. package/backend/lib/tunnel/global-tunnel-manager.ts +243 -0
  114. package/backend/lib/tunnel/project-tunnel-manager.ts +311 -0
  115. package/backend/lib/user/helpers.ts +87 -0
  116. package/backend/lib/utils/ws.ts +944 -0
  117. package/backend/lib/vite-dev.ts +353 -0
  118. package/backend/middleware/cors.ts +15 -0
  119. package/backend/middleware/error-handler.ts +49 -0
  120. package/backend/middleware/logger.ts +9 -0
  121. package/backend/types/api.ts +24 -0
  122. package/backend/ws/README.md +1505 -0
  123. package/backend/ws/chat/background.ts +198 -0
  124. package/backend/ws/chat/index.ts +21 -0
  125. package/backend/ws/chat/stream.ts +707 -0
  126. package/backend/ws/engine/claude/accounts.ts +401 -0
  127. package/backend/ws/engine/claude/index.ts +13 -0
  128. package/backend/ws/engine/claude/status.ts +43 -0
  129. package/backend/ws/engine/index.ts +14 -0
  130. package/backend/ws/engine/opencode/index.ts +11 -0
  131. package/backend/ws/engine/opencode/status.ts +30 -0
  132. package/backend/ws/engine/utils.ts +36 -0
  133. package/backend/ws/files/index.ts +30 -0
  134. package/backend/ws/files/read.ts +189 -0
  135. package/backend/ws/files/search.ts +453 -0
  136. package/backend/ws/files/watch.ts +124 -0
  137. package/backend/ws/files/write.ts +143 -0
  138. package/backend/ws/git/branch.ts +106 -0
  139. package/backend/ws/git/commit.ts +39 -0
  140. package/backend/ws/git/conflict.ts +68 -0
  141. package/backend/ws/git/diff.ts +69 -0
  142. package/backend/ws/git/index.ts +24 -0
  143. package/backend/ws/git/log.ts +41 -0
  144. package/backend/ws/git/remote.ts +214 -0
  145. package/backend/ws/git/staging.ts +84 -0
  146. package/backend/ws/git/status.ts +90 -0
  147. package/backend/ws/index.ts +69 -0
  148. package/backend/ws/mcp/index.ts +61 -0
  149. package/backend/ws/messages/crud.ts +74 -0
  150. package/backend/ws/messages/index.ts +14 -0
  151. package/backend/ws/preview/browser/cleanup.ts +129 -0
  152. package/backend/ws/preview/browser/console.ts +114 -0
  153. package/backend/ws/preview/browser/interact.ts +513 -0
  154. package/backend/ws/preview/browser/mcp.ts +129 -0
  155. package/backend/ws/preview/browser/native-ui.ts +235 -0
  156. package/backend/ws/preview/browser/stats.ts +55 -0
  157. package/backend/ws/preview/browser/tab-info.ts +126 -0
  158. package/backend/ws/preview/browser/tab.ts +166 -0
  159. package/backend/ws/preview/browser/webcodecs.ts +293 -0
  160. package/backend/ws/preview/index.ts +146 -0
  161. package/backend/ws/projects/crud.ts +113 -0
  162. package/backend/ws/projects/index.ts +25 -0
  163. package/backend/ws/projects/presence.ts +46 -0
  164. package/backend/ws/projects/status.ts +116 -0
  165. package/backend/ws/sessions/crud.ts +327 -0
  166. package/backend/ws/sessions/index.ts +33 -0
  167. package/backend/ws/settings/crud.ts +112 -0
  168. package/backend/ws/settings/index.ts +14 -0
  169. package/backend/ws/snapshot/index.ts +17 -0
  170. package/backend/ws/snapshot/restore.ts +173 -0
  171. package/backend/ws/snapshot/timeline.ts +141 -0
  172. package/backend/ws/system/index.ts +14 -0
  173. package/backend/ws/system/operations.ts +49 -0
  174. package/backend/ws/terminal/index.ts +40 -0
  175. package/backend/ws/terminal/persistence.ts +153 -0
  176. package/backend/ws/terminal/session.ts +382 -0
  177. package/backend/ws/terminal/stream.ts +79 -0
  178. package/backend/ws/tunnel/index.ts +14 -0
  179. package/backend/ws/tunnel/operations.ts +91 -0
  180. package/backend/ws/types.ts +20 -0
  181. package/backend/ws/user/crud.ts +156 -0
  182. package/backend/ws/user/index.ts +14 -0
  183. package/bin/clopen.ts +307 -0
  184. package/bun.lock +1352 -0
  185. package/frontend/App.svelte +34 -0
  186. package/frontend/app.css +313 -0
  187. package/frontend/lib/app-environment.ts +10 -0
  188. package/frontend/lib/components/chat/ChatInterface.svelte +407 -0
  189. package/frontend/lib/components/chat/formatters/ErrorMessage.svelte +57 -0
  190. package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +224 -0
  191. package/frontend/lib/components/chat/formatters/TextMessage.svelte +395 -0
  192. package/frontend/lib/components/chat/formatters/Tools.svelte +70 -0
  193. package/frontend/lib/components/chat/formatters/index.ts +3 -0
  194. package/frontend/lib/components/chat/input/ChatInput.svelte +421 -0
  195. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +78 -0
  196. package/frontend/lib/components/chat/input/components/DragDropOverlay.svelte +30 -0
  197. package/frontend/lib/components/chat/input/components/EditModeIndicator.svelte +33 -0
  198. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +619 -0
  199. package/frontend/lib/components/chat/input/components/FileAttachmentPreview.svelte +48 -0
  200. package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +31 -0
  201. package/frontend/lib/components/chat/input/composables/use-animations.svelte.ts +201 -0
  202. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +148 -0
  203. package/frontend/lib/components/chat/input/composables/use-file-handling.svelte.ts +216 -0
  204. package/frontend/lib/components/chat/input/composables/use-input-state.svelte.ts +357 -0
  205. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +57 -0
  206. package/frontend/lib/components/chat/message/ChatMessage.svelte +478 -0
  207. package/frontend/lib/components/chat/message/ChatMessages.svelte +541 -0
  208. package/frontend/lib/components/chat/message/DateSeparator.svelte +86 -0
  209. package/frontend/lib/components/chat/message/MessageBubble.svelte +86 -0
  210. package/frontend/lib/components/chat/message/MessageHeader.svelte +157 -0
  211. package/frontend/lib/components/chat/modal/DebugModal.svelte +59 -0
  212. package/frontend/lib/components/chat/modal/TokenUsageModal.svelte +124 -0
  213. package/frontend/lib/components/chat/shared/index.ts +2 -0
  214. package/frontend/lib/components/chat/shared/utils.ts +116 -0
  215. package/frontend/lib/components/chat/tools/BashOutputTool.svelte +35 -0
  216. package/frontend/lib/components/chat/tools/BashTool.svelte +46 -0
  217. package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +139 -0
  218. package/frontend/lib/components/chat/tools/EditTool.svelte +48 -0
  219. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +32 -0
  220. package/frontend/lib/components/chat/tools/GlobTool.svelte +51 -0
  221. package/frontend/lib/components/chat/tools/GrepTool.svelte +90 -0
  222. package/frontend/lib/components/chat/tools/KillShellTool.svelte +26 -0
  223. package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +31 -0
  224. package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +38 -0
  225. package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +34 -0
  226. package/frontend/lib/components/chat/tools/ReadTool.svelte +41 -0
  227. package/frontend/lib/components/chat/tools/TaskTool.svelte +64 -0
  228. package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +75 -0
  229. package/frontend/lib/components/chat/tools/WebFetchTool.svelte +35 -0
  230. package/frontend/lib/components/chat/tools/WebSearchTool.svelte +84 -0
  231. package/frontend/lib/components/chat/tools/WriteTool.svelte +33 -0
  232. package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +79 -0
  233. package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +408 -0
  234. package/frontend/lib/components/chat/tools/components/FileHeader.svelte +45 -0
  235. package/frontend/lib/components/chat/tools/components/InfoLine.svelte +19 -0
  236. package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +27 -0
  237. package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +54 -0
  238. package/frontend/lib/components/chat/tools/components/index.ts +7 -0
  239. package/frontend/lib/components/chat/tools/index.ts +26 -0
  240. package/frontend/lib/components/chat/widgets/FloatingTodoList.svelte +249 -0
  241. package/frontend/lib/components/chat/widgets/TokenUsage.svelte +78 -0
  242. package/frontend/lib/components/checkpoint/TimelineModal.svelte +391 -0
  243. package/frontend/lib/components/checkpoint/timeline/TimelineEdge.svelte +26 -0
  244. package/frontend/lib/components/checkpoint/timeline/TimelineGraph.svelte +87 -0
  245. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +108 -0
  246. package/frontend/lib/components/checkpoint/timeline/TimelineVersionGroup.svelte +59 -0
  247. package/frontend/lib/components/checkpoint/timeline/animation.ts +168 -0
  248. package/frontend/lib/components/checkpoint/timeline/config.ts +44 -0
  249. package/frontend/lib/components/checkpoint/timeline/graph-builder.ts +304 -0
  250. package/frontend/lib/components/checkpoint/timeline/types.ts +65 -0
  251. package/frontend/lib/components/checkpoint/timeline/utils.ts +53 -0
  252. package/frontend/lib/components/common/Alert.svelte +139 -0
  253. package/frontend/lib/components/common/AvatarBubble.svelte +56 -0
  254. package/frontend/lib/components/common/Button.svelte +71 -0
  255. package/frontend/lib/components/common/Card.svelte +102 -0
  256. package/frontend/lib/components/common/Checkbox.svelte +48 -0
  257. package/frontend/lib/components/common/Dialog.svelte +249 -0
  258. package/frontend/lib/components/common/FolderBrowser.svelte +843 -0
  259. package/frontend/lib/components/common/Icon.svelte +58 -0
  260. package/frontend/lib/components/common/Input.svelte +72 -0
  261. package/frontend/lib/components/common/Lightbox.svelte +233 -0
  262. package/frontend/lib/components/common/LoadingScreen.svelte +52 -0
  263. package/frontend/lib/components/common/LoadingSpinner.svelte +48 -0
  264. package/frontend/lib/components/common/Modal.svelte +177 -0
  265. package/frontend/lib/components/common/ModalProvider.svelte +28 -0
  266. package/frontend/lib/components/common/ModelSelector.svelte +110 -0
  267. package/frontend/lib/components/common/MonacoEditor.svelte +569 -0
  268. package/frontend/lib/components/common/NotificationToast.svelte +113 -0
  269. package/frontend/lib/components/common/PageTemplate.svelte +76 -0
  270. package/frontend/lib/components/common/ProjectUserAvatars.svelte +79 -0
  271. package/frontend/lib/components/common/Select.svelte +98 -0
  272. package/frontend/lib/components/common/Textarea.svelte +80 -0
  273. package/frontend/lib/components/common/ThemeToggle.svelte +44 -0
  274. package/frontend/lib/components/common/lucide-icons.ts +1642 -0
  275. package/frontend/lib/components/common/material-icons.ts +1082 -0
  276. package/frontend/lib/components/common/xterm/XTerm.svelte +796 -0
  277. package/frontend/lib/components/common/xterm/index.ts +16 -0
  278. package/frontend/lib/components/common/xterm/terminal-config.ts +68 -0
  279. package/frontend/lib/components/common/xterm/types.ts +31 -0
  280. package/frontend/lib/components/common/xterm/xterm-service.ts +353 -0
  281. package/frontend/lib/components/files/FileNode.svelte +384 -0
  282. package/frontend/lib/components/files/FileTree.svelte +681 -0
  283. package/frontend/lib/components/files/FileViewer.svelte +728 -0
  284. package/frontend/lib/components/files/SearchResults.svelte +303 -0
  285. package/frontend/lib/components/git/BranchManager.svelte +458 -0
  286. package/frontend/lib/components/git/ChangesSection.svelte +107 -0
  287. package/frontend/lib/components/git/CommitForm.svelte +76 -0
  288. package/frontend/lib/components/git/ConflictResolver.svelte +158 -0
  289. package/frontend/lib/components/git/DiffViewer.svelte +364 -0
  290. package/frontend/lib/components/git/FileChangeItem.svelte +97 -0
  291. package/frontend/lib/components/git/GitButton.svelte +33 -0
  292. package/frontend/lib/components/git/GitLog.svelte +361 -0
  293. package/frontend/lib/components/git/GitModal.svelte +80 -0
  294. package/frontend/lib/components/history/HistoryModal.svelte +563 -0
  295. package/frontend/lib/components/history/HistoryView.svelte +615 -0
  296. package/frontend/lib/components/index.ts +34 -0
  297. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +549 -0
  298. package/frontend/lib/components/preview/browser/components/Canvas.svelte +1058 -0
  299. package/frontend/lib/components/preview/browser/components/ConsolePanel.svelte +757 -0
  300. package/frontend/lib/components/preview/browser/components/Container.svelte +450 -0
  301. package/frontend/lib/components/preview/browser/components/ContextMenu.svelte +236 -0
  302. package/frontend/lib/components/preview/browser/components/SelectDropdown.svelte +224 -0
  303. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +339 -0
  304. package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +36 -0
  305. package/frontend/lib/components/preview/browser/core/cleanup.svelte.ts +155 -0
  306. package/frontend/lib/components/preview/browser/core/coordinator.svelte.ts +837 -0
  307. package/frontend/lib/components/preview/browser/core/interactions.svelte.ts +113 -0
  308. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +296 -0
  309. package/frontend/lib/components/preview/browser/core/native-ui-handlers.svelte.ts +391 -0
  310. package/frontend/lib/components/preview/browser/core/stream-handler.svelte.ts +231 -0
  311. package/frontend/lib/components/preview/browser/core/tab-manager.svelte.ts +210 -0
  312. package/frontend/lib/components/preview/browser/core/tab-operations.svelte.ts +239 -0
  313. package/frontend/lib/components/preview/index.ts +2 -0
  314. package/frontend/lib/components/settings/SettingsModal.svelte +235 -0
  315. package/frontend/lib/components/settings/SettingsView.svelte +36 -0
  316. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +51 -0
  317. package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +160 -0
  318. package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +76 -0
  319. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +917 -0
  320. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +187 -0
  321. package/frontend/lib/components/settings/general/DataManagementSettings.svelte +203 -0
  322. package/frontend/lib/components/settings/general/GeneralSettings.svelte +10 -0
  323. package/frontend/lib/components/settings/model/ModelSettings.svelte +357 -0
  324. package/frontend/lib/components/settings/notifications/NotificationSettings.svelte +205 -0
  325. package/frontend/lib/components/settings/user/UserSettings.svelte +197 -0
  326. package/frontend/lib/components/terminal/Terminal.svelte +368 -0
  327. package/frontend/lib/components/terminal/TerminalTabs.svelte +87 -0
  328. package/frontend/lib/components/terminal/TerminalView.svelte +55 -0
  329. package/frontend/lib/components/tunnel/TunnelActive.svelte +142 -0
  330. package/frontend/lib/components/tunnel/TunnelButton.svelte +54 -0
  331. package/frontend/lib/components/tunnel/TunnelInactive.svelte +284 -0
  332. package/frontend/lib/components/tunnel/TunnelModal.svelte +47 -0
  333. package/frontend/lib/components/tunnel/TunnelQRCode.svelte +49 -0
  334. package/frontend/lib/components/workspace/DesktopNavigator.svelte +382 -0
  335. package/frontend/lib/components/workspace/MobileNavigator.svelte +403 -0
  336. package/frontend/lib/components/workspace/PanelContainer.svelte +100 -0
  337. package/frontend/lib/components/workspace/PanelHeader.svelte +505 -0
  338. package/frontend/lib/components/workspace/ViewMenu.svelte +162 -0
  339. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +169 -0
  340. package/frontend/lib/components/workspace/layout/DesktopLayout.svelte +15 -0
  341. package/frontend/lib/components/workspace/layout/MobileLayout.svelte +17 -0
  342. package/frontend/lib/components/workspace/layout/split-pane/Container.svelte +42 -0
  343. package/frontend/lib/components/workspace/layout/split-pane/Handle.svelte +85 -0
  344. package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +37 -0
  345. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +274 -0
  346. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +1261 -0
  347. package/frontend/lib/components/workspace/panels/GitPanel.svelte +1560 -0
  348. package/frontend/lib/components/workspace/panels/PreviewPanel.svelte +150 -0
  349. package/frontend/lib/components/workspace/panels/TerminalPanel.svelte +73 -0
  350. package/frontend/lib/constants/preview.ts +45 -0
  351. package/frontend/lib/services/chat/chat.service.ts +704 -0
  352. package/frontend/lib/services/chat/index.ts +7 -0
  353. package/frontend/lib/services/notification/global-stream-monitor.ts +86 -0
  354. package/frontend/lib/services/notification/index.ts +8 -0
  355. package/frontend/lib/services/notification/push.service.ts +144 -0
  356. package/frontend/lib/services/notification/sound.service.ts +127 -0
  357. package/frontend/lib/services/preview/browser/browser-console.service.ts +61 -0
  358. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +1499 -0
  359. package/frontend/lib/services/preview/browser/mcp-integration.svelte.ts +67 -0
  360. package/frontend/lib/services/preview/index.ts +23 -0
  361. package/frontend/lib/services/project/index.ts +8 -0
  362. package/frontend/lib/services/project/status.service.ts +159 -0
  363. package/frontend/lib/services/snapshot/snapshot.service.ts +47 -0
  364. package/frontend/lib/services/terminal/background/index.ts +130 -0
  365. package/frontend/lib/services/terminal/background/session-restore.ts +274 -0
  366. package/frontend/lib/services/terminal/background/stream-manager.ts +286 -0
  367. package/frontend/lib/services/terminal/index.ts +14 -0
  368. package/frontend/lib/services/terminal/persistence.service.ts +260 -0
  369. package/frontend/lib/services/terminal/project.service.ts +953 -0
  370. package/frontend/lib/services/terminal/session.service.ts +364 -0
  371. package/frontend/lib/services/terminal/terminal.service.ts +369 -0
  372. package/frontend/lib/stores/core/app.svelte.ts +117 -0
  373. package/frontend/lib/stores/core/files.svelte.ts +73 -0
  374. package/frontend/lib/stores/core/presence.svelte.ts +48 -0
  375. package/frontend/lib/stores/core/projects.svelte.ts +317 -0
  376. package/frontend/lib/stores/core/sessions.svelte.ts +383 -0
  377. package/frontend/lib/stores/features/claude-accounts.svelte.ts +58 -0
  378. package/frontend/lib/stores/features/models.svelte.ts +89 -0
  379. package/frontend/lib/stores/features/settings.svelte.ts +87 -0
  380. package/frontend/lib/stores/features/terminal.svelte.ts +701 -0
  381. package/frontend/lib/stores/features/tunnel.svelte.ts +161 -0
  382. package/frontend/lib/stores/features/user.svelte.ts +96 -0
  383. package/frontend/lib/stores/ui/chat-input.svelte.ts +57 -0
  384. package/frontend/lib/stores/ui/chat-model.svelte.ts +61 -0
  385. package/frontend/lib/stores/ui/dialog.svelte.ts +59 -0
  386. package/frontend/lib/stores/ui/edit-mode.svelte.ts +214 -0
  387. package/frontend/lib/stores/ui/notification.svelte.ts +166 -0
  388. package/frontend/lib/stores/ui/settings-modal.svelte.ts +88 -0
  389. package/frontend/lib/stores/ui/theme.svelte.ts +179 -0
  390. package/frontend/lib/stores/ui/workspace.svelte.ts +754 -0
  391. package/frontend/lib/types/native-ui.ts +73 -0
  392. package/frontend/lib/utils/chat/date-separator.ts +39 -0
  393. package/frontend/lib/utils/chat/message-grouper.ts +219 -0
  394. package/frontend/lib/utils/chat/message-processor.ts +135 -0
  395. package/frontend/lib/utils/chat/tool-handler.ts +161 -0
  396. package/frontend/lib/utils/chat/virtual-scroll.svelte.ts +142 -0
  397. package/frontend/lib/utils/click-outside.ts +20 -0
  398. package/frontend/lib/utils/context-manager.ts +257 -0
  399. package/frontend/lib/utils/file-icon-mappings.ts +769 -0
  400. package/frontend/lib/utils/folder-icon-mappings.ts +1030 -0
  401. package/frontend/lib/utils/git-status.ts +68 -0
  402. package/frontend/lib/utils/platform.ts +113 -0
  403. package/frontend/lib/utils/port-check.ts +65 -0
  404. package/frontend/lib/utils/terminalFormatter.ts +207 -0
  405. package/frontend/lib/utils/theme.ts +6 -0
  406. package/frontend/lib/utils/tree-visualizer.ts +320 -0
  407. package/frontend/lib/utils/ws.ts +44 -0
  408. package/frontend/main.ts +13 -0
  409. package/index.html +70 -0
  410. package/package.json +111 -0
  411. package/scripts/generate-icons.ts +87 -0
  412. package/scripts/pre-publish-check.sh +142 -0
  413. package/scripts/setup-hooks.sh +134 -0
  414. package/scripts/validate-branch-name.sh +47 -0
  415. package/scripts/validate-commit-msg.sh +42 -0
  416. package/shared/constants/engines.ts +134 -0
  417. package/shared/types/database/connection.ts +16 -0
  418. package/shared/types/database/index.ts +6 -0
  419. package/shared/types/database/schema.ts +141 -0
  420. package/shared/types/engine/index.ts +45 -0
  421. package/shared/types/filesystem/index.ts +22 -0
  422. package/shared/types/git.ts +171 -0
  423. package/shared/types/messaging/index.ts +239 -0
  424. package/shared/types/messaging/tool.ts +526 -0
  425. package/shared/types/network/api.ts +18 -0
  426. package/shared/types/network/index.ts +5 -0
  427. package/shared/types/stores/app.ts +23 -0
  428. package/shared/types/stores/dialog.ts +21 -0
  429. package/shared/types/stores/index.ts +3 -0
  430. package/shared/types/stores/settings.ts +15 -0
  431. package/shared/types/terminal/index.ts +44 -0
  432. package/shared/types/ui/components.ts +61 -0
  433. package/shared/types/ui/icons.ts +23 -0
  434. package/shared/types/ui/index.ts +22 -0
  435. package/shared/types/ui/notifications.ts +14 -0
  436. package/shared/types/ui/theme.ts +12 -0
  437. package/shared/types/websocket/index.ts +43 -0
  438. package/shared/types/window.d.ts +13 -0
  439. package/shared/utils/anonymous-user.ts +168 -0
  440. package/shared/utils/async.ts +10 -0
  441. package/shared/utils/diff-calculator.ts +184 -0
  442. package/shared/utils/file-type-detection.ts +166 -0
  443. package/shared/utils/logger.ts +158 -0
  444. package/shared/utils/message-formatter.ts +79 -0
  445. package/shared/utils/path.ts +47 -0
  446. package/shared/utils/ws-client.ts +768 -0
  447. package/shared/utils/ws-server.ts +660 -0
  448. package/static/audio/notification.ogg +0 -0
  449. package/static/favicon.svg +8 -0
  450. package/static/fonts/dm-sans/dm-sans-italic-latin-ext.woff2 +0 -0
  451. package/static/fonts/dm-sans/dm-sans-italic-latin.woff2 +0 -0
  452. package/static/fonts/dm-sans/dm-sans-normal-latin-ext.woff2 +0 -0
  453. package/static/fonts/dm-sans/dm-sans-normal-latin.woff2 +0 -0
  454. package/static/fonts/dm-sans.css +96 -0
  455. package/svelte.config.js +20 -0
  456. package/vite.config.ts +33 -0
@@ -0,0 +1,1261 @@
1
+ <script module lang="ts">
2
+ // Persistent state that survives component destruction (mobile/desktop switch)
3
+ const projectFileStates = new Map<string, any>();
4
+ </script>
5
+
6
+ <script lang="ts">
7
+ import { projectState } from '$frontend/lib/stores/core/projects.svelte';
8
+ import FileTree from '$frontend/lib/components/files/FileTree.svelte';
9
+ import FileViewer from '$frontend/lib/components/files/FileViewer.svelte';
10
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
11
+ import Alert from '$frontend/lib/components/common/Alert.svelte';
12
+ import Dialog from '$frontend/lib/components/common/Dialog.svelte';
13
+ import type { FileNode } from '$shared/types/filesystem';
14
+ import { debug } from '$shared/utils/logger';
15
+ import { onMount, onDestroy } from 'svelte';
16
+ import ws from '$frontend/lib/utils/ws';
17
+ import { showConfirm } from '$frontend/lib/stores/ui/dialog.svelte';
18
+ import { getFileIcon } from '$frontend/lib/utils/file-icon-mappings';
19
+ import type { IconName } from '$shared/types/ui/icons';
20
+
21
+ // Props
22
+ interface Props {
23
+ showMobileHeader?: boolean;
24
+ }
25
+
26
+ const { showMobileHeader = false }: Props = $props();
27
+
28
+ // State
29
+ const hasActiveProject = $derived(projectState.currentProject !== null);
30
+ const projectPath = $derived(projectState.currentProject?.path || '');
31
+ const projectId = $derived(projectState.currentProject?.id || '');
32
+
33
+ // File watcher state
34
+ let isWatching = $state(false);
35
+ let watchDebounceTimer = $state<ReturnType<typeof setTimeout> | null>(null);
36
+
37
+ let projectFiles = $state<FileNode[]>([]);
38
+ let isLoading = $state(false);
39
+ let isInitialLoad = $state(true);
40
+ let error = $state('');
41
+ let expandedFolders = $state(new Set<string>());
42
+ let viewMode = $state<'tree' | 'viewer'>('tree');
43
+
44
+ // ============================
45
+ // Tab System
46
+ // ============================
47
+ interface EditorTab {
48
+ file: FileNode;
49
+ currentContent: string;
50
+ savedContent: string;
51
+ isLoading: boolean;
52
+ externallyChanged?: boolean;
53
+ }
54
+
55
+ let openTabs = $state<EditorTab[]>([]);
56
+ let activeTabPath = $state<string | null>(null);
57
+ let wordWrapEnabled = $state(false);
58
+
59
+ const activeTab = $derived(openTabs.find(t => t.file.path === activeTabPath) || null);
60
+ const modifiedFilePaths = $derived(new Set(
61
+ openTabs.filter(t => t.currentContent !== t.savedContent).map(t => t.file.path)
62
+ ));
63
+
64
+ // Display content for FileViewer (only updated on tab switch/load, not on every keystroke)
65
+ let displayContent = $state('');
66
+ let displaySavedContent = $state('');
67
+ let displayFile = $state<FileNode | null>(null);
68
+ let displayLoading = $state(false);
69
+ let displayTargetLine = $state<number | undefined>(undefined);
70
+ let displayExternallyChanged = $state(false);
71
+
72
+ // Sync display state when active tab changes
73
+ $effect(() => {
74
+ if (activeTab) {
75
+ displayFile = activeTab.file;
76
+ displayContent = activeTab.currentContent;
77
+ displaySavedContent = activeTab.savedContent;
78
+ displayLoading = activeTab.isLoading;
79
+ displayExternallyChanged = activeTab.externallyChanged || false;
80
+ } else {
81
+ displayFile = null;
82
+ displayContent = '';
83
+ displaySavedContent = '';
84
+ displayLoading = false;
85
+ displayExternallyChanged = false;
86
+ }
87
+ });
88
+
89
+ // Dialog state (replaces manual modals)
90
+ let dialogOpen = $state(false);
91
+ let dialogType = $state<'rename' | 'new-file' | 'new-folder'>('rename');
92
+ let dialogValue = $state('');
93
+ let dialogTargetFile = $state<FileNode | null>(null);
94
+ let dialogParentPath = $state<string | null>(null);
95
+
96
+ const dialogConfig = $derived.by(() => {
97
+ switch (dialogType) {
98
+ case 'rename':
99
+ return {
100
+ title: `Rename ${dialogTargetFile?.type === 'directory' ? 'Folder' : 'File'}`,
101
+ message: 'Enter the new name:',
102
+ placeholder: dialogTargetFile?.name || '',
103
+ confirmText: 'Rename'
104
+ };
105
+ case 'new-file':
106
+ return {
107
+ title: 'Create New File',
108
+ message: 'Enter the name for the new file:',
109
+ placeholder: 'filename.txt',
110
+ confirmText: 'Create'
111
+ };
112
+ case 'new-folder':
113
+ return {
114
+ title: 'Create New Folder',
115
+ message: 'Enter the name for the new folder:',
116
+ placeholder: 'folder-name',
117
+ confirmText: 'Create'
118
+ };
119
+ default:
120
+ return {
121
+ title: 'Input',
122
+ message: 'Enter value:',
123
+ placeholder: '',
124
+ confirmText: 'OK'
125
+ };
126
+ }
127
+ });
128
+
129
+ // Alert state
130
+ let showAlert = $state(false);
131
+ let alertMessage = $state('');
132
+ let alertTitle = $state('Error');
133
+ let alertType = $state<'info' | 'success' | 'warning' | 'error'>('error');
134
+
135
+ // projectFileStates is at module level to survive component destruction (mobile/desktop switch)
136
+ let lastProjectPath = $state<string>('');
137
+
138
+ // Container width detection for 2-column layout
139
+ let containerRef = $state<HTMLDivElement | null>(null);
140
+ let containerWidth = $state(0);
141
+ const TWO_COLUMN_THRESHOLD = 800;
142
+
143
+ // FileTree ref
144
+ let fileTreeRef = $state<any>(null);
145
+ const isTwoColumnMode = $derived(containerWidth >= TWO_COLUMN_THRESHOLD);
146
+
147
+ // Tree state preservation
148
+ let treeScrollContainer = $state<HTMLElement | null>(null);
149
+ let savedScrollPosition = 0;
150
+
151
+ // Cache key for sessionStorage
152
+ const getCacheKey = (path: string) => `files_cache_${path}`;
153
+
154
+ function saveFilesToCache(path: string, files: FileNode[]) {
155
+ try {
156
+ const cacheData = { files, timestamp: Date.now() };
157
+ sessionStorage.setItem(getCacheKey(path), JSON.stringify(cacheData));
158
+ } catch (err) {
159
+ debug.error('file', 'Failed to save files cache:', err);
160
+ }
161
+ }
162
+
163
+ function loadFilesFromCache(path: string): FileNode[] | null {
164
+ try {
165
+ const cached = sessionStorage.getItem(getCacheKey(path));
166
+ if (!cached) return null;
167
+ const cacheData = JSON.parse(cached);
168
+ if (Date.now() - cacheData.timestamp > 3600000) {
169
+ sessionStorage.removeItem(getCacheKey(path));
170
+ return null;
171
+ }
172
+ const convertDates = (file: any): FileNode => ({
173
+ ...file,
174
+ modified: new Date(file.modified),
175
+ children: file.children?.map(convertDates)
176
+ });
177
+ return cacheData.files.map(convertDates);
178
+ } catch (err) {
179
+ debug.error('file', 'Failed to load files cache:', err);
180
+ return null;
181
+ }
182
+ }
183
+
184
+ // ============================
185
+ // File Loading
186
+ // ============================
187
+
188
+ async function loadProjectFiles(preserveState = false) {
189
+ if (!hasActiveProject) {
190
+ projectFiles = [];
191
+ return;
192
+ }
193
+
194
+ if (isInitialLoad && !preserveState) {
195
+ const cachedFiles = loadFilesFromCache(projectPath);
196
+ if (cachedFiles) {
197
+ projectFiles = cachedFiles;
198
+ debug.log('file', 'Loaded files from cache, fetching fresh data...');
199
+ isLoading = false;
200
+ } else {
201
+ isLoading = true;
202
+ }
203
+ } else {
204
+ if (!preserveState) {
205
+ isLoading = true;
206
+ }
207
+ }
208
+
209
+ let savedExpandedFolders: Set<string> | null = null;
210
+ if (preserveState) {
211
+ savedExpandedFolders = new Set(expandedFolders);
212
+ if (treeScrollContainer) {
213
+ savedScrollPosition = treeScrollContainer.scrollTop;
214
+ }
215
+ }
216
+
217
+ error = '';
218
+
219
+ try {
220
+ const requestData: any = { project_path: projectPath };
221
+ if (preserveState && savedExpandedFolders && savedExpandedFolders.size > 0) {
222
+ requestData.expanded = Array.from(savedExpandedFolders).join(',');
223
+ }
224
+
225
+ const data = await ws.http('files:list-tree', requestData);
226
+
227
+ const convertToFileNode = (apiFile: unknown): FileNode => {
228
+ if (typeof apiFile !== 'object' || apiFile === null) {
229
+ throw new Error('Invalid file object');
230
+ }
231
+ const file = apiFile as Record<string, unknown>;
232
+ return {
233
+ name: String(file.name || ''),
234
+ path: String(file.path || ''),
235
+ type: file.type === 'directory' ? 'directory' : 'file',
236
+ size: typeof file.size === 'number' ? file.size : 0,
237
+ modified: file.modified ? new Date(String(file.modified)) : new Date(),
238
+ children: Array.isArray(file.children) ? file.children.map(convertToFileNode) : undefined
239
+ };
240
+ };
241
+
242
+ const rootFile = convertToFileNode(data);
243
+ projectFiles = rootFile.type === 'directory' && rootFile.children ? rootFile.children : [rootFile];
244
+
245
+ saveFilesToCache(projectPath, projectFiles);
246
+
247
+ if (isInitialLoad) {
248
+ isInitialLoad = false;
249
+ }
250
+
251
+ if (preserveState && savedExpandedFolders) {
252
+ expandedFolders = savedExpandedFolders;
253
+ requestAnimationFrame(() => {
254
+ requestAnimationFrame(() => {
255
+ if (treeScrollContainer) {
256
+ treeScrollContainer.scrollTop = savedScrollPosition;
257
+ }
258
+ });
259
+ });
260
+ }
261
+ } catch (err) {
262
+ error = err instanceof Error ? err.message : 'Failed to load files';
263
+ projectFiles = [];
264
+ } finally {
265
+ isLoading = false;
266
+ }
267
+ }
268
+
269
+ async function loadDirectoryContents(dirPath: string): Promise<FileNode[]> {
270
+ try {
271
+ const data = await ws.http('files:list-directory', { dir_path: dirPath });
272
+ if (Array.isArray(data)) {
273
+ return data.map((item: any) => ({
274
+ ...item,
275
+ modified: new Date(item.modified)
276
+ }));
277
+ }
278
+ return [];
279
+ } catch (error) {
280
+ return [];
281
+ }
282
+ }
283
+
284
+ function updateFileTreeChildren(files: FileNode[], targetPath: string, newChildren: FileNode[]): FileNode[] {
285
+ return files.map((file) => {
286
+ if (file.path === targetPath) return { ...file, children: newChildren };
287
+ if (file.type === 'directory' && file.children) {
288
+ return { ...file, children: updateFileTreeChildren(file.children, targetPath, newChildren) };
289
+ }
290
+ return file;
291
+ });
292
+ }
293
+
294
+ function findFileInTree(files: FileNode[], targetPath: string): FileNode | null {
295
+ for (const file of files) {
296
+ if (file.path === targetPath) return file;
297
+ if (file.type === 'directory' && file.children) {
298
+ const found = findFileInTree(file.children, targetPath);
299
+ if (found) return found;
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+
305
+ // ============================
306
+ // Tab Operations
307
+ // ============================
308
+
309
+ function openFileInTab(file: FileNode, targetLine?: number) {
310
+ if (file.type === 'directory') return;
311
+
312
+ // Check if tab already exists
313
+ const existingTab = openTabs.find(t => t.file.path === file.path);
314
+ if (existingTab) {
315
+ activeTabPath = file.path;
316
+ displayTargetLine = targetLine;
317
+ if (!isTwoColumnMode) viewMode = 'viewer';
318
+ return;
319
+ }
320
+
321
+ // Create new tab
322
+ const newTab: EditorTab = {
323
+ file,
324
+ currentContent: '',
325
+ savedContent: '',
326
+ isLoading: true
327
+ };
328
+ openTabs = [...openTabs, newTab];
329
+ activeTabPath = file.path;
330
+ displayTargetLine = targetLine;
331
+ if (!isTwoColumnMode) viewMode = 'viewer';
332
+
333
+ // Load content
334
+ loadTabContent(file.path);
335
+ }
336
+
337
+ async function loadTabContent(filePath: string): Promise<boolean> {
338
+ try {
339
+ const data = await ws.http('files:read-file', { file_path: filePath });
340
+ const content = data.content || '';
341
+ openTabs = openTabs.map(t =>
342
+ t.file.path === filePath
343
+ ? { ...t, currentContent: content, savedContent: content, isLoading: false }
344
+ : t
345
+ );
346
+ // Update display if this is the active tab
347
+ if (filePath === activeTabPath) {
348
+ displayContent = content;
349
+ displaySavedContent = content;
350
+ displayLoading = false;
351
+ }
352
+ return true;
353
+ } catch (err) {
354
+ openTabs = openTabs.map(t =>
355
+ t.file.path === filePath
356
+ ? { ...t, isLoading: false }
357
+ : t
358
+ );
359
+ if (filePath === activeTabPath) {
360
+ displayLoading = false;
361
+ }
362
+ return false;
363
+ }
364
+ }
365
+
366
+ function selectTab(path: string) {
367
+ activeTabPath = path;
368
+ displayTargetLine = undefined;
369
+
370
+ // Directly sync display state for immediate editor update
371
+ const tab = openTabs.find(t => t.file.path === path);
372
+ if (tab) {
373
+ displayFile = tab.file;
374
+ displayContent = tab.currentContent;
375
+ displaySavedContent = tab.savedContent;
376
+ displayLoading = tab.isLoading;
377
+ }
378
+ }
379
+
380
+ // Scroll to active file in tree (without auto-expanding parent folders)
381
+ function scrollToActiveFile(filePath: string) {
382
+ if (!filePath || !projectPath) return;
383
+
384
+ // Auto-scroll to the selected file in the tree after DOM update (only if visible)
385
+ requestAnimationFrame(() => {
386
+ requestAnimationFrame(() => {
387
+ const selectedEl = treeScrollContainer?.querySelector('.selected');
388
+ if (selectedEl && treeScrollContainer) {
389
+ const containerRect = treeScrollContainer.getBoundingClientRect();
390
+ const elRect = selectedEl.getBoundingClientRect();
391
+ const padding = 40;
392
+
393
+ if (elRect.top < containerRect.top + padding) {
394
+ treeScrollContainer.scrollTop -= (containerRect.top + padding - elRect.top);
395
+ } else if (elRect.bottom > containerRect.bottom - padding) {
396
+ treeScrollContainer.scrollTop += (elRect.bottom - containerRect.bottom + padding);
397
+ }
398
+ }
399
+ });
400
+ });
401
+ }
402
+
403
+ function closeTab(path: string) {
404
+ const idx = openTabs.findIndex(t => t.file.path === path);
405
+ if (idx === -1) return;
406
+
407
+ openTabs = openTabs.filter(t => t.file.path !== path);
408
+
409
+ if (activeTabPath === path) {
410
+ if (openTabs.length > 0) {
411
+ // Activate the nearest tab
412
+ const newIdx = Math.min(idx, openTabs.length - 1);
413
+ activeTabPath = openTabs[newIdx].file.path;
414
+ } else {
415
+ activeTabPath = null;
416
+ if (!isTwoColumnMode) viewMode = 'tree';
417
+ }
418
+ }
419
+ }
420
+
421
+ function handleEditorContentChange(newContent: string) {
422
+ if (!activeTabPath) return;
423
+ openTabs = openTabs.map(t =>
424
+ t.file.path === activeTabPath
425
+ ? { ...t, currentContent: newContent }
426
+ : t
427
+ );
428
+ }
429
+
430
+ // ============================
431
+ // Tree Manipulation Helpers (Optimistic UI)
432
+ // ============================
433
+
434
+ function updateNodePathInTree(files: FileNode[], oldPath: string, newPath: string): FileNode[] {
435
+ return files.map((file) => {
436
+ if (file.path === oldPath) {
437
+ const newName = newPath.split(/[\\/]/).pop() || file.name;
438
+ return { ...file, path: newPath, name: newName };
439
+ } else if (file.type === 'directory' && file.children) {
440
+ return { ...file, children: updateNodePathInTree(file.children, oldPath, newPath) };
441
+ }
442
+ return file;
443
+ });
444
+ }
445
+
446
+ function removeNodeFromTree(files: FileNode[], targetPath: string): FileNode[] {
447
+ return files
448
+ .filter((file) => file.path !== targetPath)
449
+ .map((file) => {
450
+ if (file.type === 'directory' && file.children) {
451
+ return { ...file, children: removeNodeFromTree(file.children, targetPath) };
452
+ }
453
+ return file;
454
+ });
455
+ }
456
+
457
+ function addNodeToTree(files: FileNode[], parentPath: string | null, newNode: FileNode): FileNode[] {
458
+ if (!parentPath) {
459
+ return sortFileNodes([...files, newNode]);
460
+ }
461
+ return files.map((file) => {
462
+ if (file.path === parentPath && file.type === 'directory') {
463
+ return { ...file, children: sortFileNodes([...(file.children || []), newNode]) };
464
+ } else if (file.type === 'directory' && file.children) {
465
+ return { ...file, children: addNodeToTree(file.children, parentPath, newNode) };
466
+ }
467
+ return file;
468
+ });
469
+ }
470
+
471
+ function duplicateNodeInTree(files: FileNode[], sourcePath: string, targetPath: string): FileNode[] {
472
+ const sourceNode = findFileInTree(files, sourcePath);
473
+ if (!sourceNode) return files;
474
+ const newName = targetPath.split(/[\\/]/).pop() || sourceNode.name;
475
+ const duplicateNode: FileNode = { ...sourceNode, path: targetPath, name: newName };
476
+ const pathParts = targetPath.split(/[\\/]/);
477
+ pathParts.pop();
478
+ const parentPath = pathParts.length > 0 ? pathParts.join(targetPath.includes('\\') ? '\\' : '/') : null;
479
+ return addNodeToTree(files, parentPath, duplicateNode);
480
+ }
481
+
482
+ function moveNodeInTree(files: FileNode[], sourcePath: string, targetPath: string): FileNode[] {
483
+ const sourceNode = findFileInTree(files, sourcePath);
484
+ if (!sourceNode) return files;
485
+ const newTree = removeNodeFromTree(files, sourcePath);
486
+ const newName = targetPath.split(/[\\/]/).pop() || sourceNode.name;
487
+ const movedNode: FileNode = { ...sourceNode, path: targetPath, name: newName };
488
+ const pathParts = targetPath.split(/[\\/]/);
489
+ pathParts.pop();
490
+ const parentPath = pathParts.length > 0 ? pathParts.join(targetPath.includes('\\') ? '\\' : '/') : null;
491
+ return addNodeToTree(newTree, parentPath, movedNode);
492
+ }
493
+
494
+ function sortFileNodes(nodes: FileNode[]): FileNode[] {
495
+ return nodes.sort((a, b) => {
496
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
497
+ return a.name.localeCompare(b.name);
498
+ });
499
+ }
500
+
501
+ function generateUniqueFilename(basePath: string, filename: string): string {
502
+ const separator = basePath.includes('\\') ? '\\' : '/';
503
+ let targetPath = `${basePath}${separator}${filename}`;
504
+ const existingFile = findFileInTree(projectFiles, targetPath);
505
+ if (!existingFile) return targetPath;
506
+ const lastDotIndex = filename.lastIndexOf('.');
507
+ const name = lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename;
508
+ const extension = lastDotIndex > 0 ? filename.substring(lastDotIndex) : '';
509
+ let counter = 1;
510
+ while (true) {
511
+ const numberedFilename = `${name} (${counter})${extension}`;
512
+ targetPath = `${basePath}${separator}${numberedFilename}`;
513
+ if (!findFileInTree(projectFiles, targetPath)) return targetPath;
514
+ counter++;
515
+ }
516
+ }
517
+
518
+ // ============================
519
+ // Alert/Dialog Helpers
520
+ // ============================
521
+
522
+ function showErrorAlert(message: string, title = 'Error') {
523
+ alertType = 'error';
524
+ alertTitle = title;
525
+ alertMessage = message;
526
+ showAlert = true;
527
+ }
528
+
529
+ function openDialog(type: 'rename' | 'new-file' | 'new-folder', file?: FileNode, parentPath?: string | null) {
530
+ dialogType = type;
531
+ dialogTargetFile = file || null;
532
+ dialogParentPath = parentPath ?? null;
533
+ dialogValue = type === 'rename' ? (file?.name || '') : '';
534
+ dialogOpen = true;
535
+ }
536
+
537
+ function closeDialog() {
538
+ dialogOpen = false;
539
+ dialogValue = '';
540
+ dialogTargetFile = null;
541
+ dialogParentPath = null;
542
+ }
543
+
544
+ async function handleDialogConfirm(value?: string) {
545
+ const trimmedValue = (value || dialogValue || '').trim();
546
+ if (!trimmedValue) {
547
+ closeDialog();
548
+ return;
549
+ }
550
+
551
+ switch (dialogType) {
552
+ case 'rename':
553
+ await performRename(trimmedValue);
554
+ break;
555
+ case 'new-file':
556
+ await performCreateNew('file', trimmedValue);
557
+ break;
558
+ case 'new-folder':
559
+ await performCreateNew('folder', trimmedValue);
560
+ break;
561
+ }
562
+ closeDialog();
563
+ }
564
+
565
+ async function performRename(newName: string) {
566
+ const file = dialogTargetFile;
567
+ if (!file || newName === file.name) return;
568
+
569
+ try {
570
+ const pathParts = file.path.split(/[\\/]/);
571
+ pathParts[pathParts.length - 1] = newName;
572
+ const newPath = pathParts.join(file.path.includes('\\') ? '\\' : '/');
573
+ const oldPath = file.path;
574
+
575
+ const existingFile = findFileInTree(projectFiles, newPath);
576
+ if (existingFile) {
577
+ showErrorAlert('A file or folder with this name already exists.', 'Rename Failed');
578
+ return;
579
+ }
580
+
581
+ // Optimistic UI
582
+ projectFiles = updateNodePathInTree(projectFiles, oldPath, newPath);
583
+
584
+ // Update tab if open
585
+ openTabs = openTabs.map(t => {
586
+ if (t.file.path === oldPath) {
587
+ return { ...t, file: { ...t.file, path: newPath, name: newName } };
588
+ }
589
+ return t;
590
+ });
591
+ if (activeTabPath === oldPath) {
592
+ activeTabPath = newPath;
593
+ }
594
+
595
+ await ws.http('files:rename', { oldPath, newPath });
596
+ } catch (error) {
597
+ debug.error('file', 'Failed to rename file:', error);
598
+ showErrorAlert(error instanceof Error ? error.message : 'Unknown error', 'Rename Failed');
599
+ }
600
+ }
601
+
602
+ async function performCreateNew(type: 'file' | 'folder', name: string) {
603
+ try {
604
+ const parentPath = dialogParentPath;
605
+ const separator = (parentPath || projectPath).includes('\\') ? '\\' : '/';
606
+ const fullPath = parentPath
607
+ ? `${parentPath}${separator}${name}`
608
+ : `${projectPath}${separator}${name}`;
609
+
610
+ const newNode: FileNode = {
611
+ name,
612
+ path: fullPath,
613
+ type: type === 'folder' ? 'directory' : 'file',
614
+ size: 0,
615
+ modified: new Date(),
616
+ children: type === 'folder' ? [] : undefined
617
+ };
618
+
619
+ // Optimistic UI
620
+ projectFiles = addNodeToTree(projectFiles, parentPath, newNode);
621
+
622
+ if (parentPath && !expandedFolders.has(parentPath)) {
623
+ expandedFolders.add(parentPath);
624
+ expandedFolders = new Set(expandedFolders);
625
+ }
626
+
627
+ if (type === 'file') {
628
+ await ws.http('files:create-file', { filePath: fullPath, content: '' });
629
+ } else {
630
+ await ws.http('files:create-directory', { dirPath: fullPath });
631
+ }
632
+ } catch (error) {
633
+ debug.error('file', 'Failed to create new item:', error);
634
+ showErrorAlert(error instanceof Error ? error.message : 'Unknown error', 'Create Failed');
635
+ }
636
+ }
637
+
638
+ // ============================
639
+ // Clipboard
640
+ // ============================
641
+
642
+ let clipboard = $state<{ file: FileNode; operation: 'copy' | 'cut' } | null>(null);
643
+
644
+ async function pasteFile(targetFolder: FileNode) {
645
+ if (!clipboard) return;
646
+ try {
647
+ const { file: sourceFile, operation } = clipboard;
648
+ let basePath: string;
649
+ if (targetFolder.type === 'directory') {
650
+ basePath = targetFolder.path;
651
+ } else {
652
+ const pathParts = targetFolder.path.split(/[\\/]/);
653
+ pathParts.pop();
654
+ basePath = pathParts.join(targetFolder.path.includes('\\') ? '\\' : '/');
655
+ }
656
+ const targetPath = generateUniqueFilename(basePath, sourceFile.name);
657
+
658
+ if (operation === 'copy') {
659
+ projectFiles = duplicateNodeInTree(projectFiles, sourceFile.path, targetPath);
660
+ if (targetFolder.type === 'directory' && !expandedFolders.has(targetFolder.path)) {
661
+ expandedFolders.add(targetFolder.path);
662
+ expandedFolders = new Set(expandedFolders);
663
+ }
664
+ await ws.http('files:duplicate', { sourcePath: sourceFile.path, targetPath });
665
+ } else if (operation === 'cut') {
666
+ projectFiles = moveNodeInTree(projectFiles, sourceFile.path, targetPath);
667
+ if (targetFolder.type === 'directory' && !expandedFolders.has(targetFolder.path)) {
668
+ expandedFolders.add(targetFolder.path);
669
+ expandedFolders = new Set(expandedFolders);
670
+ }
671
+ const savedClipboard = clipboard;
672
+ clipboard = null;
673
+ try {
674
+ await ws.http('files:rename', { oldPath: sourceFile.path, newPath: targetPath });
675
+ } catch (err) {
676
+ projectFiles = moveNodeInTree(projectFiles, targetPath, sourceFile.path);
677
+ clipboard = savedClipboard;
678
+ throw err;
679
+ }
680
+ }
681
+ } catch (error) {
682
+ debug.error('file', 'Failed to paste file:', error);
683
+ showErrorAlert(error instanceof Error ? error.message : 'Unknown error', 'Paste Failed');
684
+ }
685
+ }
686
+
687
+ async function pasteToRoot() {
688
+ if (!clipboard || !projectPath) return;
689
+ try {
690
+ const { file: sourceFile, operation } = clipboard;
691
+ const targetPath = generateUniqueFilename(projectPath, sourceFile.name);
692
+
693
+ if (operation === 'copy') {
694
+ projectFiles = duplicateNodeInTree(projectFiles, sourceFile.path, targetPath);
695
+ await ws.http('files:duplicate', { sourcePath: sourceFile.path, targetPath });
696
+ } else if (operation === 'cut') {
697
+ projectFiles = moveNodeInTree(projectFiles, sourceFile.path, targetPath);
698
+ const savedClipboard = clipboard;
699
+ clipboard = null;
700
+ try {
701
+ await ws.http('files:rename', { oldPath: sourceFile.path, newPath: targetPath });
702
+ } catch (err) {
703
+ projectFiles = moveNodeInTree(projectFiles, targetPath, sourceFile.path);
704
+ clipboard = savedClipboard;
705
+ throw err;
706
+ }
707
+ }
708
+ } catch (error) {
709
+ debug.error('file', 'Failed to paste to root:', error);
710
+ showErrorAlert(error instanceof Error ? error.message : 'Unknown error', 'Paste Failed');
711
+ }
712
+ }
713
+
714
+ // ============================
715
+ // File Actions
716
+ // ============================
717
+
718
+ async function handleFolderToggle(folderPath: string) {
719
+ if (expandedFolders.has(folderPath)) {
720
+ expandedFolders.delete(folderPath);
721
+ } else {
722
+ expandedFolders.add(folderPath);
723
+ const folder = findFileInTree(projectFiles, folderPath);
724
+ if (folder && folder.type === 'directory' && (!folder.children || folder.children.length === 0)) {
725
+ const children = await loadDirectoryContents(folderPath);
726
+ projectFiles = updateFileTreeChildren(projectFiles, folderPath, children);
727
+ }
728
+ }
729
+ expandedFolders = new Set(expandedFolders);
730
+ }
731
+
732
+ function handleFileSelect(file: FileNode) {
733
+ if (file.type === 'file') {
734
+ openFileInTab(file);
735
+ }
736
+ }
737
+
738
+ async function handleFileOpen(filePath: string, lineNumber?: number) {
739
+ let file = findFileInTree(projectFiles, filePath);
740
+ if (!file) {
741
+ const fileName = filePath.split(/[/\\]/).pop() || 'Untitled';
742
+ file = { name: fileName, path: filePath, type: 'file', size: 0, modified: new Date() };
743
+ }
744
+ openFileInTab(file, lineNumber);
745
+ }
746
+
747
+ async function handleFileAction(action: string, file: FileNode) {
748
+ switch (action) {
749
+ case 'copy-path':
750
+ navigator.clipboard.writeText(file.path);
751
+ break;
752
+ case 'copy-relative-path': {
753
+ let relativePath = file.path;
754
+ if (projectPath && file.path.startsWith(projectPath)) {
755
+ relativePath = file.path.substring(projectPath.length);
756
+ if (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
757
+ relativePath = relativePath.substring(1);
758
+ }
759
+ }
760
+ navigator.clipboard.writeText(relativePath);
761
+ break;
762
+ }
763
+ case 'rename':
764
+ openDialog('rename', file);
765
+ break;
766
+ case 'copy':
767
+ clipboard = { file, operation: 'copy' };
768
+ break;
769
+ case 'cut':
770
+ clipboard = { file, operation: 'cut' };
771
+ break;
772
+ case 'paste':
773
+ if (clipboard) await pasteFile(file);
774
+ break;
775
+ case 'new-file':
776
+ openDialog('new-file', undefined, file.type === 'directory' ? file.path : null);
777
+ break;
778
+ case 'new-folder':
779
+ openDialog('new-folder', undefined, file.type === 'directory' ? file.path : null);
780
+ break;
781
+ case 'duplicate':
782
+ await duplicateFile(file);
783
+ break;
784
+ case 'delete':
785
+ await deleteFile(file);
786
+ break;
787
+ case 'refresh':
788
+ await refreshAll();
789
+ break;
790
+ }
791
+ }
792
+
793
+ async function deleteFile(file: FileNode) {
794
+ const confirmMessage = file.type === 'directory'
795
+ ? `Are you sure you want to delete the folder "${file.name}" and all its contents?`
796
+ : `Are you sure you want to delete "${file.name}"?`;
797
+
798
+ const confirmed = await showConfirm({
799
+ title: file.type === 'directory' ? 'Delete Folder' : 'Delete File',
800
+ message: confirmMessage,
801
+ type: 'error',
802
+ confirmText: 'Delete',
803
+ cancelText: 'Cancel'
804
+ });
805
+ if (!confirmed) return;
806
+
807
+ try {
808
+ const deletedNode = findFileInTree(projectFiles, file.path);
809
+ projectFiles = removeNodeFromTree(projectFiles, file.path);
810
+
811
+ // Close tab if open
812
+ closeTab(file.path);
813
+
814
+ try {
815
+ await ws.http('files:delete', { filePath: file.path, force: file.type === 'directory' });
816
+ } catch (err) {
817
+ if (deletedNode) {
818
+ const pathParts = file.path.split(/[\\/]/);
819
+ pathParts.pop();
820
+ const parentPath = pathParts.length > 0 ? pathParts.join(file.path.includes('\\') ? '\\' : '/') : null;
821
+ projectFiles = addNodeToTree(projectFiles, parentPath, deletedNode);
822
+ }
823
+ throw err;
824
+ }
825
+ } catch (error) {
826
+ debug.error('file', 'Failed to delete file:', error);
827
+ showErrorAlert(error instanceof Error ? error.message : 'Unknown error', 'Delete Failed');
828
+ }
829
+ }
830
+
831
+ async function duplicateFile(file: FileNode) {
832
+ try {
833
+ const pathParts = file.path.split(/[\\/]/);
834
+ const fileName = pathParts.pop() || file.name;
835
+ const parentPath = pathParts.join(file.path.includes('\\') ? '\\' : '/');
836
+ const targetPath = generateUniqueFilename(parentPath, fileName);
837
+
838
+ // Optimistic UI
839
+ projectFiles = duplicateNodeInTree(projectFiles, file.path, targetPath);
840
+
841
+ await ws.http('files:duplicate', { sourcePath: file.path, targetPath });
842
+ } catch (error) {
843
+ debug.error('file', 'Failed to duplicate file:', error);
844
+ showErrorAlert(error instanceof Error ? error.message : 'Unknown error', 'Duplicate Failed');
845
+ // Reload to revert optimistic update
846
+ await loadProjectFiles(true);
847
+ }
848
+ }
849
+
850
+ function createNewFileInRoot() {
851
+ openDialog('new-file', undefined, null);
852
+ }
853
+
854
+ function createNewFolderInRoot() {
855
+ openDialog('new-folder', undefined, null);
856
+ }
857
+
858
+ // Save file
859
+ async function saveFile(filePath: string, content: string) {
860
+ await ws.http('files:write-file', { filePath, content });
861
+ // Update tab's savedContent and clear external change flag
862
+ openTabs = openTabs.map(t =>
863
+ t.file.path === filePath
864
+ ? { ...t, savedContent: content, externallyChanged: false }
865
+ : t
866
+ );
867
+ // Update display if active tab
868
+ if (filePath === activeTabPath) {
869
+ displaySavedContent = content;
870
+ displayExternallyChanged = false;
871
+ }
872
+ }
873
+
874
+ // Force reload active tab from server (discard local changes)
875
+ async function forceReloadTab() {
876
+ if (!activeTabPath) return;
877
+ const success = await loadTabContent(activeTabPath);
878
+ if (success) {
879
+ openTabs = openTabs.map(t =>
880
+ t.file.path === activeTabPath
881
+ ? { ...t, externallyChanged: false }
882
+ : t
883
+ );
884
+ displayExternallyChanged = false;
885
+ }
886
+ }
887
+
888
+ // Refresh all
889
+ async function refreshAll(preserveState = false) {
890
+ await loadProjectFiles(preserveState);
891
+ }
892
+
893
+ // ============================
894
+ // Effects
895
+ // ============================
896
+
897
+ // Load files when project changes
898
+ $effect(() => {
899
+ if (hasActiveProject) {
900
+ // Save current project state before switching
901
+ if (lastProjectPath && lastProjectPath !== projectPath) {
902
+ projectFileStates.set(lastProjectPath, {
903
+ openTabs: [...openTabs],
904
+ activeTabPath,
905
+ expandedFolders: new Set(expandedFolders),
906
+ viewMode
907
+ });
908
+ }
909
+
910
+ if (lastProjectPath !== projectPath) {
911
+ isInitialLoad = true;
912
+ }
913
+
914
+ loadProjectFiles();
915
+
916
+ // Restore previous state for this project
917
+ if (projectPath) {
918
+ const savedState = projectFileStates.get(projectPath);
919
+ if (savedState) {
920
+ openTabs = savedState.openTabs;
921
+ activeTabPath = savedState.activeTabPath;
922
+ expandedFolders = savedState.expandedFolders;
923
+ viewMode = savedState.viewMode;
924
+ } else {
925
+ openTabs = [];
926
+ activeTabPath = null;
927
+ viewMode = 'tree';
928
+ }
929
+ lastProjectPath = projectPath;
930
+ }
931
+ } else {
932
+ openTabs = [];
933
+ activeTabPath = null;
934
+ viewMode = 'tree';
935
+ lastProjectPath = '';
936
+ isInitialLoad = true;
937
+ }
938
+ });
939
+
940
+ // Start/stop file watcher when project changes
941
+ $effect(() => {
942
+ if (hasActiveProject && projectId && projectPath) {
943
+ ws.emit('files:watch', { projectPath });
944
+ debug.log('file', `Started watching project: ${projectId}`);
945
+
946
+ return () => {
947
+ ws.emit('files:unwatch', {});
948
+ isWatching = false;
949
+ debug.log('file', `Stopped watching project: ${projectId}`);
950
+ };
951
+ }
952
+ });
953
+
954
+ // Listen for file change events
955
+ $effect(() => {
956
+ if (!hasActiveProject || !projectId) return;
957
+
958
+ // Accumulate changes across multiple events within the debounce window
959
+ let accumulatedChanges: Array<{ path: string; type: 'created' | 'modified' | 'deleted'; timestamp: string }> = [];
960
+
961
+ const unsubChanges = ws.on('files:changed', (payload) => {
962
+ if (payload.projectId !== projectId) return;
963
+
964
+ // Accumulate changes from all events before debounce fires
965
+ accumulatedChanges.push(...payload.changes);
966
+
967
+ if (watchDebounceTimer) clearTimeout(watchDebounceTimer);
968
+
969
+ watchDebounceTimer = setTimeout(async () => {
970
+ const changes = [...accumulatedChanges];
971
+ accumulatedChanges = [];
972
+
973
+ // Reload file tree
974
+ await loadProjectFiles(true);
975
+
976
+ // Refresh all open tabs after tree reload
977
+ const tabsSnapshot = [...openTabs];
978
+ for (const tab of tabsSnapshot) {
979
+ const hasUnsavedChanges = tab.currentContent !== tab.savedContent;
980
+
981
+ if (hasUnsavedChanges) {
982
+ // Tab has unsaved changes — check if file changed externally
983
+ try {
984
+ const data = await ws.http('files:read-file', { file_path: tab.file.path });
985
+ const serverContent = data.content || '';
986
+ if (serverContent !== tab.savedContent) {
987
+ openTabs = openTabs.map(t =>
988
+ t.file.path === tab.file.path
989
+ ? { ...t, externallyChanged: true }
990
+ : t
991
+ );
992
+ if (tab.file.path === activeTabPath) {
993
+ displayExternallyChanged = true;
994
+ }
995
+ }
996
+ } catch {
997
+ // File deleted/renamed while user has unsaved changes — keep tab open
998
+ }
999
+ continue;
1000
+ }
1001
+
1002
+ // Tab has no unsaved changes — auto-sync
1003
+ const success = await loadTabContent(tab.file.path);
1004
+ if (!success) {
1005
+ // File no longer exists — try to detect rename
1006
+ const tabDir = tab.file.path.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
1007
+
1008
+ // Find a 'created' change in the same parent directory
1009
+ const renameTarget = changes.find(c => {
1010
+ if (c.type !== 'created') return false;
1011
+ const cDir = c.path.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
1012
+ return cDir === tabDir;
1013
+ });
1014
+
1015
+ if (renameTarget) {
1016
+ const newPath = renameTarget.path;
1017
+ const newName = newPath.split(/[\\/]/).pop() || tab.file.name;
1018
+ const oldPath = tab.file.path;
1019
+
1020
+ // Update tab path and name
1021
+ openTabs = openTabs.map(t =>
1022
+ t.file.path === oldPath
1023
+ ? { ...t, file: { ...t.file, path: newPath, name: newName } }
1024
+ : t
1025
+ );
1026
+ if (activeTabPath === oldPath) {
1027
+ activeTabPath = newPath;
1028
+ }
1029
+
1030
+ // Load content from new path
1031
+ await loadTabContent(newPath);
1032
+ } else {
1033
+ // No rename target found — file was truly deleted
1034
+ closeTab(tab.file.path);
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ watchDebounceTimer = null;
1040
+ }, 500);
1041
+ });
1042
+
1043
+ const unsubWatching = ws.on('files:watching', (payload) => {
1044
+ if (payload.projectId !== projectId) return;
1045
+ isWatching = payload.watching;
1046
+ });
1047
+
1048
+ const unsubError = ws.on('files:watch-error', (payload) => {
1049
+ if (payload.projectId !== projectId) return;
1050
+ debug.error('file', `Watch error: ${payload.error}`);
1051
+ });
1052
+
1053
+ return () => {
1054
+ unsubChanges();
1055
+ unsubWatching();
1056
+ unsubError();
1057
+ if (watchDebounceTimer) clearTimeout(watchDebounceTimer);
1058
+ };
1059
+ });
1060
+
1061
+ // Scroll to active file in tree when active tab changes
1062
+ $effect(() => {
1063
+ if (activeTabPath) {
1064
+ scrollToActiveFile(activeTabPath);
1065
+ }
1066
+ });
1067
+
1068
+ // Sync view state when switching between 1-column and 2-column modes
1069
+ let prevTwoColumnMode = $state<boolean | null>(null);
1070
+ $effect(() => {
1071
+ if (prevTwoColumnMode !== null && prevTwoColumnMode !== isTwoColumnMode) {
1072
+ if (!isTwoColumnMode) {
1073
+ // Switching from 2-column to 1-column:
1074
+ // If there's an active tab, show the viewer; otherwise show tree
1075
+ if (activeTabPath && openTabs.length > 0) {
1076
+ viewMode = 'viewer';
1077
+ } else {
1078
+ viewMode = 'tree';
1079
+ }
1080
+ }
1081
+ // Switching from 1-column to 2-column: both are shown, no action needed
1082
+ }
1083
+ prevTwoColumnMode = isTwoColumnMode;
1084
+ });
1085
+
1086
+ // Save state to persistent storage on component destruction (mobile/desktop switch)
1087
+ onDestroy(() => {
1088
+ if (projectPath) {
1089
+ // When in 2-column mode, compute correct viewMode for 1-column restoration
1090
+ let savedViewMode = viewMode;
1091
+ if (isTwoColumnMode) {
1092
+ savedViewMode = (activeTabPath && openTabs.length > 0) ? 'viewer' : 'tree';
1093
+ }
1094
+ projectFileStates.set(projectPath, {
1095
+ openTabs: [...openTabs],
1096
+ activeTabPath,
1097
+ expandedFolders: new Set(expandedFolders),
1098
+ viewMode: savedViewMode
1099
+ });
1100
+ }
1101
+ });
1102
+
1103
+ // Monitor container width for responsive layout
1104
+ onMount(() => {
1105
+ if (containerRef && typeof ResizeObserver !== 'undefined') {
1106
+ const resizeObserver = new ResizeObserver((entries) => {
1107
+ for (const entry of entries) {
1108
+ containerWidth = entry.contentRect.width;
1109
+ }
1110
+ });
1111
+ resizeObserver.observe(containerRef);
1112
+ return () => resizeObserver.disconnect();
1113
+ }
1114
+ });
1115
+
1116
+ // ============================
1117
+ // Panel Actions Export
1118
+ // ============================
1119
+
1120
+ export const panelActions = {
1121
+ setViewMode: (mode: 'tree' | 'viewer') => {
1122
+ if (!isTwoColumnMode) viewMode = mode;
1123
+ },
1124
+ getViewMode: () => viewMode,
1125
+ canShowViewer: () => openTabs.length > 0,
1126
+ isTwoColumnMode: () => isTwoColumnMode
1127
+ };
1128
+ </script>
1129
+
1130
+ <!-- Tab Bar Snippet -->
1131
+ {#snippet tabBar()}
1132
+ {#if openTabs.length > 0}
1133
+ <div class="flex items-center border-b border-slate-200 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/50 overflow-x-auto flex-shrink-0">
1134
+ {#each openTabs as tab (tab.file.path)}
1135
+ {@const isActive = tab.file.path === activeTabPath}
1136
+ {@const isModified = tab.currentContent !== tab.savedContent}
1137
+ <div
1138
+ class="flex items-center gap-1.5 px-3 py-2 text-xs border-r border-slate-200/50 dark:border-slate-700/50 whitespace-nowrap transition-colors flex-shrink-0 cursor-pointer {isActive
1139
+ ? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100'
1140
+ : 'text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-700 dark:hover:text-slate-300'}"
1141
+ onclick={() => selectTab(tab.file.path)}
1142
+ >
1143
+ <Icon name={getFileIcon(tab.file.name) as IconName} class="w-3.5 h-3.5 flex-shrink-0" />
1144
+ <span class="truncate max-w-28">{tab.file.name}</span>
1145
+ {#if isModified}
1146
+ <span class="w-1.5 h-1.5 rounded-full bg-amber-500 dark:bg-amber-600 flex-shrink-0"></span>
1147
+ {/if}
1148
+ <button
1149
+ class="flex p-0.5 hover:bg-slate-200 dark:hover:bg-slate-700 rounded flex-shrink-0 opacity-60 hover:opacity-100"
1150
+ onclick={(e) => { e.stopPropagation(); closeTab(tab.file.path); }}
1151
+ title="Close tab"
1152
+ >
1153
+ <Icon name="lucide:x" class="w-3 h-3" />
1154
+ </button>
1155
+ </div>
1156
+ {/each}
1157
+ </div>
1158
+ {/if}
1159
+ {/snippet}
1160
+
1161
+ <div class="h-full flex flex-col bg-transparent" bind:this={containerRef}>
1162
+ {#if !hasActiveProject}
1163
+ <div class="flex-1 flex flex-col items-center justify-center gap-3 text-slate-600 dark:text-slate-500 text-sm">
1164
+ <Icon name="lucide:folder" class="w-10 h-10 opacity-30" />
1165
+ <span>No project selected</span>
1166
+ </div>
1167
+ {:else if isLoading && projectFiles.length === 0}
1168
+ <div class="flex-1 flex flex-col items-center justify-center gap-3 text-slate-600 dark:text-slate-500 text-sm">
1169
+ <div class="w-6 h-6 border-2 border-slate-200 dark:border-slate-800 border-t-violet-600 rounded-full animate-spin"></div>
1170
+ <span>Loading files...</span>
1171
+ </div>
1172
+ {:else if error}
1173
+ <div class="flex-1 flex flex-col items-center justify-center gap-3 text-red-500 text-sm">
1174
+ <Icon name="lucide:circle-x" class="w-8 h-8" />
1175
+ <span>{error}</span>
1176
+ <button
1177
+ onclick={() => loadProjectFiles()}
1178
+ class="py-1.5 px-3.5 bg-violet-500/10 dark:bg-violet-500/15 border border-violet-500/20 rounded-md text-violet-600 text-xs cursor-pointer transition-all duration-150 hover:bg-violet-500/20 dark:hover:bg-violet-500/25"
1179
+ >Retry</button>
1180
+ </div>
1181
+ {:else}
1182
+ <div class="flex-1 overflow-hidden">
1183
+ <!-- Unified layout: always render both Tree and Viewer to preserve internal state -->
1184
+ <div class="h-full flex">
1185
+ <!-- Tree panel: always rendered, hidden via CSS in 1-column viewer mode -->
1186
+ <div
1187
+ class={isTwoColumnMode
1188
+ ? 'w-80 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700'
1189
+ : (viewMode === 'tree' ? 'w-full h-full overflow-hidden' : 'hidden')}
1190
+ >
1191
+ <div class="h-full overflow-auto" bind:this={treeScrollContainer}>
1192
+ <FileTree
1193
+ bind:this={fileTreeRef}
1194
+ files={projectFiles}
1195
+ selectedFile={displayFile}
1196
+ activeFilePath={activeTabPath}
1197
+ {expandedFolders}
1198
+ onFileSelect={handleFileSelect}
1199
+ onFileAction={handleFileAction}
1200
+ onFileOpen={handleFileOpen}
1201
+ onToggle={handleFolderToggle}
1202
+ hasClipboard={clipboard !== null}
1203
+ onPasteToRoot={pasteToRoot}
1204
+ onNewFileInRoot={createNewFileInRoot}
1205
+ onNewFolderInRoot={createNewFolderInRoot}
1206
+ onRefresh={refreshAll}
1207
+ modifiedFiles={modifiedFilePaths}
1208
+ />
1209
+ </div>
1210
+ </div>
1211
+
1212
+ <!-- Editor panel: always rendered, hidden via CSS in 1-column tree mode -->
1213
+ <div
1214
+ class={isTwoColumnMode
1215
+ ? 'flex-1 h-full overflow-hidden flex flex-col'
1216
+ : (viewMode === 'viewer' ? 'w-full h-full flex flex-col' : 'hidden')}
1217
+ >
1218
+ {@render tabBar()}
1219
+ <div class="flex-1 overflow-hidden">
1220
+ <FileViewer
1221
+ file={displayFile}
1222
+ content={displayContent}
1223
+ savedContent={displaySavedContent}
1224
+ isLoading={displayLoading}
1225
+ error=""
1226
+ onSave={saveFile}
1227
+ targetLine={displayTargetLine}
1228
+ onContentChange={handleEditorContentChange}
1229
+ wordWrap={wordWrapEnabled}
1230
+ onToggleWordWrap={() => { wordWrapEnabled = !wordWrapEnabled; }}
1231
+ externallyChanged={displayExternallyChanged}
1232
+ onForceReload={forceReloadTab}
1233
+ />
1234
+ </div>
1235
+ </div>
1236
+ </div>
1237
+ </div>
1238
+ {/if}
1239
+
1240
+ <!-- Dialog for Rename / Create -->
1241
+ <Dialog
1242
+ bind:isOpen={dialogOpen}
1243
+ type="info"
1244
+ title={dialogConfig.title}
1245
+ message={dialogConfig.message}
1246
+ bind:inputValue={dialogValue}
1247
+ inputPlaceholder={dialogConfig.placeholder}
1248
+ confirmText={dialogConfig.confirmText}
1249
+ onConfirm={handleDialogConfirm}
1250
+ onClose={closeDialog}
1251
+ />
1252
+
1253
+ <!-- Alert Component -->
1254
+ <Alert
1255
+ bind:isOpen={showAlert}
1256
+ title={alertTitle}
1257
+ message={alertMessage}
1258
+ type={alertType}
1259
+ onClose={() => { showAlert = false; }}
1260
+ />
1261
+ </div>