@oar-sh/oar 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,2112 @@
1
+ import {
2
+ BASE,
3
+ escHtml,
4
+ formatBytes,
5
+ FILE_PREVIEW_MAX_BYTES,
6
+ MAX_UPLOAD_ATTACHMENTS,
7
+ REPO_IMAGE_EXTENSIONS,
8
+ currentConvId,
9
+ selectedAttachments,
10
+ filePreviewState,
11
+ repoBrowserState,
12
+ workspaceRootPath,
13
+ showTransientRelayNotice,
14
+ getConversationCurrentWorkspaceRootPath,
15
+ } from './store.js';
16
+ import {
17
+ uploadAttachment,
18
+ loadWorkspaceFilePreview,
19
+ loadDriveFilePreview,
20
+ loadRepoTree,
21
+ loadRepoChildren,
22
+ loadDrivesRoots,
23
+ loadDriveChildren,
24
+ loadSessionRootTree,
25
+ } from './api-client.js';
26
+ import {
27
+ extractPastedFiles,
28
+ extractDroppedFiles,
29
+ isGenericClipboardName,
30
+ pastedFileName,
31
+ planAttachmentMerge,
32
+ overCapNoticeText,
33
+ } from './composer-paste.mjs';
34
+ import { reencodeReason, downscaleImageFile, browserDownscaleDeps } from './image-downscale.mjs';
35
+ import { imageAttachmentWarningText } from './model-vision-support.mjs';
36
+ import {
37
+ normalizeWorkspaceMentionPath,
38
+ normalizeDriveBrowserPath,
39
+ driveFileHrefFromPath,
40
+ workspacePreviewApiPath,
41
+ drivePreviewApiPath,
42
+ renderMarkdownPreview,
43
+ rewriteLocalAssetUrlsInNode,
44
+ buildReferenceToken,
45
+ copyTextToClipboard,
46
+ copyReferenceTokenToClipboard,
47
+ eventClosest,
48
+ } from './router.js';
49
+ import { markdownHeadingId, resolveFilePreviewLink } from './file-preview-navigation.mjs';
50
+ import { openExternalNavigation } from './external-link-policy.mjs';
51
+ import { attachCodeCopyButtons } from './code-copy.mjs';
52
+ import {
53
+ deepestExistingAncestor,
54
+ planRepoRehydration,
55
+ repoAncestorPaths,
56
+ } from './repo-browser-tree-state.mjs';
57
+ import {
58
+ writeRepoBrowserHeavyPreference,
59
+ writeRepoBrowserHiddenPreference,
60
+ } from './repo-browser-preferences.mjs';
61
+ import { joinLaunchCwdPath } from './launch-cwd-path.mjs';
62
+
63
+ function currentConversationId() {
64
+ return String(currentConvId || '').trim();
65
+ }
66
+
67
+ function isVideoMimeType(mimeType) {
68
+ return String(mimeType || '').toLowerCase().startsWith('video/');
69
+ }
70
+
71
+ function normalizeVideoPreviewOptions(options = {}) {
72
+ const startSeconds = Math.max(0, Number(options?.startSeconds ?? options?.startAtSeconds ?? 0) || 0);
73
+ const preload = String(options?.preload || 'metadata').toLowerCase();
74
+ return {
75
+ startSeconds,
76
+ preload: preload === 'auto' ? 'auto' : 'metadata',
77
+ autoplay: options?.autoplay === true,
78
+ };
79
+ }
80
+
81
+ function currentWorkspaceRootPathForSelection() {
82
+ return String(getConversationCurrentWorkspaceRootPath(currentConversationId()) || workspaceRootPath || '').trim();
83
+ }
84
+
85
+ function currentWorkspaceScopeSuffix() {
86
+ const convId = currentConversationId();
87
+ return convId ? `?conversationId=${encodeURIComponent(convId)}` : '';
88
+ }
89
+
90
+ function setFilePreviewState(next) {
91
+ Object.assign(filePreviewState, next);
92
+ }
93
+
94
+ function setRepoBrowserState(next) {
95
+ Object.assign(repoBrowserState, next);
96
+ }
97
+
98
+ let repoBrowserReloadQueued = false;
99
+ // A refresh (hidden/heavy toggle, Refresh button) refetches a lazy tree, so the
100
+ // expansion it wants to restore has to be re-applied once the new root lands.
101
+ // The restore is parked here rather than chained onto refreshRepoBrowser's own
102
+ // await, because loadRepoBrowserTree returns immediately when another load is
103
+ // already in flight — see the tail of loadRepoBrowserTree.
104
+ let pendingRepoBrowserRestore = null;
105
+ let repoBrowserRefreshSeq = 0;
106
+ let repoBrowserRenderSuspended = 0;
107
+ let repoBrowserRenderDirty = false;
108
+ let lastTreeScrolledPath = null;
109
+ const filePreviewHistory = [];
110
+
111
+ function takePendingRepoBrowserRestore() {
112
+ const restore = pendingRepoBrowserRestore;
113
+ pendingRepoBrowserRestore = null;
114
+ return restore;
115
+ }
116
+
117
+ // Bumping the seq also invalidates any restore already in flight.
118
+ function parkRepoBrowserRestore() {
119
+ pendingRepoBrowserRestore = {
120
+ path: String(repoBrowserState.currentPath || ''),
121
+ seq: (repoBrowserRefreshSeq += 1),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Rehydrating a branch calls renderRepoBrowser twice per directory, so a deep
127
+ * restore would otherwise rebuild the tree's innerHTML dozens of times and
128
+ * flicker. Renders are collapsed into one at the end instead.
129
+ */
130
+ async function withSuspendedRepoRender(fn) {
131
+ repoBrowserRenderSuspended += 1;
132
+ try {
133
+ return await fn();
134
+ } finally {
135
+ repoBrowserRenderSuspended -= 1;
136
+ if (repoBrowserRenderSuspended === 0 && repoBrowserRenderDirty) {
137
+ repoBrowserRenderDirty = false;
138
+ renderRepoBrowser();
139
+ }
140
+ }
141
+ }
142
+
143
+ function resolveAttachmentContentUrl(rawValue) {
144
+ const value = String(rawValue || '').trim();
145
+ if (!value) return '';
146
+ if (/^(?:https?:|data:|blob:)/i.test(value)) return value;
147
+ const normalizedBase = String(BASE || '').trim().replace(/\/+$/, '');
148
+ if (normalizedBase && value.startsWith(`${normalizedBase}/`)) return value;
149
+ if (value.startsWith('/')) return `${BASE}${value}`;
150
+ return value;
151
+ }
152
+
153
+ function flushQueuedRepoBrowserReload() {
154
+ if (!repoBrowserState.open) {
155
+ repoBrowserReloadQueued = false;
156
+ return;
157
+ }
158
+ if (!repoBrowserReloadQueued || repoBrowserState.loading) return;
159
+ repoBrowserReloadQueued = false;
160
+ void loadRepoBrowserTree();
161
+ }
162
+
163
+ export function renderAttachmentMarkup(attachments, { messageId = '' } = {}) {
164
+ return `<div class="msg-attachments">${
165
+ attachments.map((att) => {
166
+ const name = escHtml(att?.name || 'attachment');
167
+ const type = escHtml(att?.type || 'file');
168
+ const rawUrl = att?.dataUrl
169
+ ? att.dataUrl
170
+ : att?.contentUrl
171
+ ? resolveAttachmentContentUrl(att.contentUrl)
172
+ : '';
173
+ const isImage = String(att?.type || '').startsWith('image/');
174
+ const isVideo = isVideoMimeType(att?.type);
175
+ const sizeText = Number(att?.size || 0) > 0 ? ` · ${formatBytes(Number(att.size || 0))}` : '';
176
+ const continuity = att?.generatedImage?.continuity;
177
+ const continuityActions = continuity?.canEdit && messageId
178
+ ? `<div class="msg-image-continuity-actions">
179
+ <button type="button" onclick="setImageEditTarget(${escHtml(JSON.stringify({
180
+ messageId,
181
+ imageId: att.generatedImage.imageId,
182
+ nodeId: continuity.nodeId,
183
+ name: att.name || 'generated image',
184
+ }))})">Edit this image</button>
185
+ ${continuity.parentMessageId
186
+ ? `<button type="button" onclick="jumpToImageParent(${escHtml(JSON.stringify(continuity.parentMessageId))})">Edited from previous image</button>`
187
+ : ''}
188
+ </div>`
189
+ : '';
190
+ if ((isImage || isVideo) && rawUrl) {
191
+ const jsName = escHtml(JSON.stringify(att?.name || 'attachment'));
192
+ const jsUrl = escHtml(JSON.stringify(rawUrl));
193
+ const jsType = escHtml(JSON.stringify(att?.type || 'image/jpeg'));
194
+ const openHandler = `openUploadedAttachmentViewer(${jsName},${jsUrl},${jsType})`;
195
+ return `
196
+ <div class="msg-attachment msg-attachment-${isVideo ? 'video' : 'image'}">
197
+ ${isVideo
198
+ ? `<div class="msg-attachment-video-chip">🎞️</div>`
199
+ : `<img src="${escHtml(rawUrl)}" alt="${name}" loading="eager" decoding="async" onclick="${openHandler}">`}
200
+ <div class="msg-attachment-meta"><a href="#" onclick="${openHandler};return false;">${name}</a> · ${type}${sizeText} · <a href="#" onclick="${openHandler};return false;">open</a>${continuityActions}</div>
201
+ </div>`;
202
+ }
203
+ if (rawUrl) {
204
+ return `
205
+ <div class="msg-attachment">
206
+ <div class="msg-attachment-meta">📎 <a href="${escHtml(rawUrl)}" target="_blank" rel="noopener noreferrer">${name}</a> · ${type}${sizeText}</div>
207
+ </div>`;
208
+ }
209
+ return `
210
+ <div class="msg-attachment">
211
+ <div class="msg-attachment-meta">📎 ${name} · ${type}${sizeText}</div>
212
+ </div>`;
213
+ }).join('')
214
+ }</div>`;
215
+ }
216
+
217
+ export function renderAttachmentPreview() {
218
+ const el = document.getElementById('attachment-preview');
219
+ if (!selectedAttachments.length) {
220
+ el.innerHTML = '';
221
+ el.classList.remove('visible');
222
+ window.syncComposerControlState?.();
223
+ syncComposerAttachmentWarning();
224
+ return;
225
+ }
226
+
227
+ el.innerHTML = selectedAttachments.map((att, idx) => {
228
+ const state = String(att?.uploadState || 'uploaded');
229
+ const thumb = att.isImage && att.previewUrl
230
+ ? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}">`
231
+ : `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
232
+ const overlay = state === 'pending' || state === 'uploading'
233
+ ? `<div class="attachment-preview-status attachment-preview-status-uploading" role="status" aria-label="Uploading ${escHtml(att.name)}"><span class="attachment-preview-spinner"></span></div>`
234
+ : state === 'error'
235
+ ? `<button type="button" class="attachment-preview-status attachment-preview-status-error" onclick="retryAttachmentUpload(${idx})" title="${escHtml(att.error || 'Upload failed')} — click to retry">⟳</button>`
236
+ : '';
237
+ return `
238
+ <div class="attachment-preview-item attachment-preview-${escHtml(state)}">
239
+ <button class="attachment-preview-remove" onclick="removeAttachment(${idx})" title="Remove">×</button>
240
+ ${thumb}
241
+ ${overlay}
242
+ <div class="attachment-preview-meta">${escHtml(att.name)}${att.size ? ` · ${formatBytes(att.size)}` : ''}</div>
243
+ </div>
244
+ `;
245
+ }).join('');
246
+ el.classList.add('visible');
247
+ window.syncComposerControlState?.();
248
+ syncComposerAttachmentWarning();
249
+ }
250
+
251
+ function syncComposerAttachmentWarning() {
252
+ const el = document.getElementById('composer-attachment-warning');
253
+ if (!el) return;
254
+ const modelId = document.getElementById('model-select')?.value || '';
255
+ const text = imageAttachmentWarningText(modelId, selectedAttachments);
256
+ el.textContent = text;
257
+ el.hidden = !text;
258
+ }
259
+
260
+ export function refreshComposerAttachmentWarning() {
261
+ syncComposerAttachmentWarning();
262
+ }
263
+
264
+ function releaseAttachmentPreviewUrl(attachment) {
265
+ // Hydrated draft attachments point at a server URL, which must not be revoked.
266
+ if (!attachment?.previewUrl) return;
267
+ if (attachment.previewUrlIsObjectUrl === false) return;
268
+ URL.revokeObjectURL(attachment.previewUrl);
269
+ }
270
+
271
+ export function removeAttachment(idx) {
272
+ const [removed] = selectedAttachments.splice(idx, 1);
273
+ releaseAttachmentPreviewUrl(removed);
274
+ renderAttachmentPreview();
275
+ // Persisting the remaining list lets the server diff it and release the draft
276
+ // reference itself. Going through the normal draft save keeps removals ordered
277
+ // against in-flight draft writes instead of racing them.
278
+ window.persistComposerAttachments?.();
279
+ }
280
+
281
+ export function clearAttachments() {
282
+ for (const att of selectedAttachments) {
283
+ releaseAttachmentPreviewUrl(att);
284
+ }
285
+ selectedAttachments.length = 0;
286
+ renderAttachmentPreview();
287
+ }
288
+
289
+ /**
290
+ * Replaces composer attachments wholesale, used when hydrating a conversation's
291
+ * cached draft attachments. Object URLs from the outgoing set are released.
292
+ */
293
+ export function setComposerAttachments(attachments = []) {
294
+ for (const att of selectedAttachments) {
295
+ releaseAttachmentPreviewUrl(att);
296
+ }
297
+ selectedAttachments.length = 0;
298
+ selectedAttachments.push(...(Array.isArray(attachments) ? attachments.filter(Boolean) : []));
299
+ renderAttachmentPreview();
300
+ }
301
+
302
+ let attachmentIdSeq = 0;
303
+
304
+ function nextAttachmentId() {
305
+ attachmentIdSeq += 1;
306
+ return `att-${Date.now()}-${attachmentIdSeq}`;
307
+ }
308
+
309
+ async function prepareIncomingFile(file, { source, now, index }) {
310
+ const rawType = String(file?.type || '').trim().toLowerCase() || 'application/octet-stream';
311
+ const isImage = rawType.startsWith('image/');
312
+
313
+ // Clipboard bitmaps arrive unnamed (or as a generic "image.png"), so they get a
314
+ // timestamped name that survives the round trip to the server.
315
+ const needsGeneratedName = source === 'paste' && isGenericClipboardName(file?.name);
316
+ let prepared = file;
317
+ // Re-encode when the image is oversized, and also when its format is one the
318
+ // model cannot read (a small WebP would otherwise arrive invisible).
319
+ if (isImage && reencodeReason(file)) {
320
+ prepared = await downscaleImageFile(file, {}, browserDownscaleDeps());
321
+ }
322
+
323
+ const finalType = String(prepared?.type || rawType).trim().toLowerCase() || 'application/octet-stream';
324
+ const finalIsImage = finalType.startsWith('image/');
325
+ const name = needsGeneratedName
326
+ ? pastedFileName(finalType, now, index)
327
+ : String(prepared?.name || file?.name || 'upload');
328
+
329
+ return {
330
+ id: nextAttachmentId(),
331
+ name,
332
+ type: finalType,
333
+ size: Number(prepared?.size || 0),
334
+ file: prepared,
335
+ isImage: finalIsImage,
336
+ previewUrl: finalIsImage ? URL.createObjectURL(prepared) : '',
337
+ previewUrlIsObjectUrl: finalIsImage,
338
+ uploadState: 'pending',
339
+ uploaded: null,
340
+ error: '',
341
+ };
342
+ }
343
+
344
+ async function startAttachmentUpload(attachment, ownerConversationId = currentConversationId()) {
345
+ if (!attachment?.file) return;
346
+ attachment.uploadState = 'uploading';
347
+ attachment.error = '';
348
+ renderAttachmentPreview();
349
+ try {
350
+ const payload = await uploadAttachment(attachment);
351
+ if (!payload?.attachment) throw new Error('Upload returned no attachment');
352
+ // The entry may have been removed, or the user may have moved to another
353
+ // conversation, while the upload was in flight.
354
+ if (!selectedAttachments.includes(attachment)) return;
355
+ attachment.uploaded = payload.attachment;
356
+ attachment.sha256 = payload.attachment.sha256;
357
+ attachment.uploadState = 'uploaded';
358
+ attachment.error = '';
359
+ renderAttachmentPreview();
360
+ if (currentConversationId() !== ownerConversationId) return;
361
+ window.persistComposerAttachments?.();
362
+ } catch (e) {
363
+ if (!selectedAttachments.includes(attachment)) return;
364
+ attachment.uploadState = 'error';
365
+ attachment.error = e?.message || 'Upload failed';
366
+ renderAttachmentPreview();
367
+ showTransientRelayNotice(`Upload failed for ${attachment.name}. Click the chip to retry.`);
368
+ }
369
+ }
370
+
371
+ export function retryAttachmentUpload(idx) {
372
+ const attachment = selectedAttachments[idx];
373
+ if (!attachment || attachment.uploadState !== 'error') return;
374
+ void startAttachmentUpload(attachment);
375
+ }
376
+
377
+ /**
378
+ * Single ingestion path shared by the file picker, paste and drag-and-drop.
379
+ * Files are normalized, optionally downscaled, capped, and uploaded immediately
380
+ * so that pressing Send never has to wait on the network.
381
+ */
382
+ export async function ingestFiles(files, { source = 'picker' } = {}) {
383
+ const inputFiles = Array.from(files || []).filter(Boolean);
384
+ if (!inputFiles.length) return [];
385
+
386
+ // Downscaling a large image takes long enough for the user to switch
387
+ // conversations, and the result must never land in the wrong composer.
388
+ const ownerConversationId = currentConversationId();
389
+ const now = new Date();
390
+ const prepared = [];
391
+ for (let index = 0; index < inputFiles.length; index += 1) {
392
+ prepared.push(await prepareIncomingFile(inputFiles[index], { source, now, index }));
393
+ }
394
+
395
+ if (currentConversationId() !== ownerConversationId) {
396
+ for (const item of prepared) releaseAttachmentPreviewUrl(item);
397
+ return [];
398
+ }
399
+
400
+ const plan = planAttachmentMerge(selectedAttachments, prepared, MAX_UPLOAD_ATTACHMENTS);
401
+ const rejected = prepared.filter((item) => !plan.acceptedAdditions.includes(item));
402
+ for (const item of rejected) {
403
+ releaseAttachmentPreviewUrl(item);
404
+ }
405
+
406
+ selectedAttachments.length = 0;
407
+ selectedAttachments.push(...plan.accepted);
408
+ renderAttachmentPreview();
409
+
410
+ if (plan.droppedCount > 0) {
411
+ showTransientRelayNotice(overCapNoticeText(plan.droppedCount, MAX_UPLOAD_ATTACHMENTS));
412
+ }
413
+
414
+ await Promise.all(plan.acceptedAdditions.map(
415
+ (attachment) => startAttachmentUpload(attachment, ownerConversationId),
416
+ ));
417
+ return plan.acceptedAdditions;
418
+ }
419
+
420
+ export async function handleAttachmentInput(files) {
421
+ return ingestFiles(files, { source: 'picker' });
422
+ }
423
+
424
+ export async function handleComposerPaste(event) {
425
+ const { files } = extractPastedFiles(event?.clipboardData);
426
+ if (!files.length) return false;
427
+ // Only claim the event once a file is actually present, so pasting plain text
428
+ // keeps its native behaviour.
429
+ event.preventDefault?.();
430
+ await ingestFiles(files, { source: 'paste' });
431
+ return true;
432
+ }
433
+
434
+ export async function handleComposerDrop(event) {
435
+ const { files } = extractDroppedFiles(event?.dataTransfer);
436
+ if (!files.length) return false;
437
+ event.preventDefault?.();
438
+ await ingestFiles(files, { source: 'drop' });
439
+ return true;
440
+ }
441
+
442
+ export async function uploadAttachments(files) {
443
+ const items = Array.isArray(files) ? files : [];
444
+ const uploaded = [];
445
+ for (const item of items) {
446
+ if (!item) continue;
447
+ // Eagerly uploaded attachments already hold their server payload; only
448
+ // entries that never made it (offline, failed, still queued) are retried.
449
+ if (item.uploaded?.sha256) {
450
+ uploaded.push(item.uploaded);
451
+ continue;
452
+ }
453
+ if (!item.file) continue;
454
+ const payload = await uploadAttachment(item);
455
+ if (!payload?.attachment) throw new Error('Upload returned no attachment');
456
+ item.uploaded = payload.attachment;
457
+ item.sha256 = payload.attachment.sha256;
458
+ item.uploadState = 'uploaded';
459
+ uploaded.push(payload.attachment);
460
+ }
461
+ return uploaded;
462
+ }
463
+
464
+ function updateFilePreviewUiState() {
465
+ const previewBtn = document.getElementById('file-preview-mode-preview');
466
+ const rawBtn = document.getElementById('file-preview-mode-raw');
467
+ const htmlBtn = document.getElementById('file-preview-html-toggle');
468
+ const warning = document.getElementById('file-preview-warning');
469
+ const payload = filePreviewState.payload;
470
+ const isMarkdown = payload?.kind === 'markdown';
471
+ const isUpload = filePreviewState.source === 'upload';
472
+ previewBtn.style.display = isUpload ? 'none' : '';
473
+ rawBtn.style.display = isUpload ? 'none' : '';
474
+ previewBtn.classList.toggle('active', filePreviewState.mode === 'preview');
475
+ rawBtn.classList.toggle('active', filePreviewState.mode === 'raw');
476
+ htmlBtn.style.display = (!isUpload && isMarkdown && filePreviewState.mode === 'preview') ? 'inline-block' : 'none';
477
+ htmlBtn.classList.toggle('active', filePreviewState.allowHtml);
478
+ htmlBtn.textContent = filePreviewState.allowHtml ? 'Show literal HTML' : 'Show HTML layout';
479
+ warning.classList.toggle('visible', isMarkdown && filePreviewState.allowHtml && filePreviewState.mode === 'preview');
480
+ const backBtn = document.getElementById('file-preview-back');
481
+ backBtn.hidden = filePreviewHistory.length === 0;
482
+ backBtn.disabled = filePreviewHistory.length === 0;
483
+ }
484
+
485
+ function snapshotFilePreviewState() {
486
+ const bodyEl = document.getElementById('file-preview-body');
487
+ return {
488
+ path: filePreviewState.path,
489
+ source: filePreviewState.source,
490
+ mode: filePreviewState.mode,
491
+ allowHtml: filePreviewState.allowHtml,
492
+ loading: filePreviewState.loading,
493
+ error: filePreviewState.error,
494
+ payload: filePreviewState.payload,
495
+ viewerOptions: filePreviewState.viewerOptions,
496
+ scrollTop: Number(bodyEl?.scrollTop || 0),
497
+ };
498
+ }
499
+
500
+ function scrollMarkdownPreviewToFragment(rawFragment) {
501
+ const fragment = markdownHeadingId(rawFragment);
502
+ if (!fragment) return;
503
+ const article = document.querySelector('#file-preview-body .file-preview-markdown');
504
+ const target = Array.from(article?.querySelectorAll('[id]') || []).find((element) => element.id === fragment);
505
+ target?.scrollIntoView({ block: 'start' });
506
+ }
507
+
508
+ function assignMarkdownHeadingIds(article) {
509
+ const counts = new Map();
510
+ for (const heading of article.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
511
+ const baseId = markdownHeadingId(heading.textContent);
512
+ if (!baseId) continue;
513
+ const count = counts.get(baseId) || 0;
514
+ counts.set(baseId, count + 1);
515
+ heading.id = count ? `${baseId}-${count}` : baseId;
516
+ }
517
+ }
518
+
519
+ function teardownImageZoom() {
520
+ const c = imgZoom.container;
521
+ const img = imgZoom.imgEl;
522
+ const onImgLoad = imgZoom.onImgLoad;
523
+ window.removeEventListener('resize', _imgZoomOnResize);
524
+ if (img && onImgLoad) img.removeEventListener('load', onImgLoad);
525
+ if (c) {
526
+ c.removeEventListener('wheel', _imgZoomWheel);
527
+ c.removeEventListener('mousedown', _imgZoomMouseDown);
528
+ c.removeEventListener('dblclick', _imgZoomDblClick);
529
+ c.removeEventListener('touchstart', _imgZoomTouchStart);
530
+ c.removeEventListener('touchmove', _imgZoomTouchMove);
531
+ c.removeEventListener('touchend', _imgZoomTouchEnd);
532
+ imgZoom.container = null;
533
+ }
534
+ imgZoom.imgEl = null;
535
+ imgZoom.onImgLoad = null;
536
+ imgZoom.baseW = 0;
537
+ imgZoom.baseH = 0;
538
+ _cancelImgZoomCrispen();
539
+ const bodyEl = document.getElementById('file-preview-body');
540
+ if (bodyEl) bodyEl.classList.remove('image-zoom-mode');
541
+ }
542
+
543
+ function teardownVideoPreview() {
544
+ const video = videoPreview.videoEl;
545
+ const onLoadedMetadata = videoPreview.onLoadedMetadata;
546
+ const onError = videoPreview.onError;
547
+ const onCanPlay = videoPreview.onCanPlay;
548
+ if (video) {
549
+ if (onLoadedMetadata) video.removeEventListener('loadedmetadata', onLoadedMetadata);
550
+ if (onError) video.removeEventListener('error', onError);
551
+ if (onCanPlay) video.removeEventListener('canplay', onCanPlay);
552
+ try {
553
+ video.pause();
554
+ } catch {}
555
+ }
556
+ videoPreview.videoEl = null;
557
+ videoPreview.onLoadedMetadata = null;
558
+ videoPreview.onError = null;
559
+ videoPreview.onCanPlay = null;
560
+ const bodyEl = document.getElementById('file-preview-body');
561
+ if (bodyEl) bodyEl.classList.remove('video-preview-mode');
562
+ }
563
+
564
+ function setupImageZoom(container) {
565
+ imgZoom.container = container;
566
+ imgZoom.imgEl = container.querySelector('img');
567
+ // Stale from the previously viewed image, and wrong for this one.
568
+ imgZoom.baseW = 0;
569
+ imgZoom.baseH = 0;
570
+ imgZoom.minScale = 1;
571
+ imgZoom.scale = 1;
572
+ imgZoom.panX = 0;
573
+ imgZoom.panY = 0;
574
+ imgZoom.dragging = false;
575
+ imgZoom.pinching = false;
576
+ imgZoom.lastTapMs = 0;
577
+ container.addEventListener('wheel', _imgZoomWheel, { passive: false });
578
+ container.addEventListener('mousedown', _imgZoomMouseDown);
579
+ container.addEventListener('dblclick', _imgZoomDblClick);
580
+ container.addEventListener('touchstart', _imgZoomTouchStart, { passive: false });
581
+ container.addEventListener('touchmove', _imgZoomTouchMove, { passive: false });
582
+ container.addEventListener('touchend', _imgZoomTouchEnd);
583
+ window.addEventListener('resize', _imgZoomOnResize);
584
+ if (imgZoom.imgEl) {
585
+ imgZoom.onImgLoad = () => _recomputeImgZoomMinScale({ resetToMin: true });
586
+ imgZoom.imgEl.addEventListener('load', imgZoom.onImgLoad, { once: true });
587
+ }
588
+ requestAnimationFrame(() => _recomputeImgZoomMinScale({ resetToMin: true }));
589
+ }
590
+
591
+ let imgZoom = {
592
+ scale: 1,
593
+ panX: 0,
594
+ panY: 0,
595
+ minScale: 1,
596
+ dragging: false,
597
+ lastX: 0,
598
+ lastY: 0,
599
+ pinching: false,
600
+ pinchDist0: 0,
601
+ pinchScale0: 1,
602
+ pinchPanX0: 0,
603
+ pinchPanY0: 0,
604
+ pinchCX: 0,
605
+ pinchCY: 0,
606
+ lastTapMs: 0,
607
+ container: null,
608
+ imgEl: null,
609
+ onImgLoad: null,
610
+ baseW: 0,
611
+ baseH: 0,
612
+ };
613
+ let videoPreview = {
614
+ videoEl: null,
615
+ onLoadedMetadata: null,
616
+ onError: null,
617
+ onCanPlay: null,
618
+ };
619
+ const IMG_ZOOM_MIN_FLOOR = 0.05;
620
+ const IMG_ZOOM_MAX = 8;
621
+ const IMG_ZOOM_EPS = 0.001;
622
+
623
+ function _isAtImgZoomMin() {
624
+ return Math.abs(imgZoom.scale - imgZoom.minScale) <= IMG_ZOOM_EPS;
625
+ }
626
+
627
+ /**
628
+ * The image's laid-out size at scale 1, measured once with the zoom styles
629
+ * removed. Deriving it from the live rect instead would be circular now that
630
+ * zooming changes the element's layout size rather than only its transform.
631
+ */
632
+ function _measureImgBaseSize() {
633
+ const img = imgZoom.container?.querySelector('img');
634
+ if (!img) return null;
635
+ const prev = { width: img.style.width, height: img.style.height, maxWidth: img.style.maxWidth, maxHeight: img.style.maxHeight, transform: img.style.transform };
636
+ img.style.width = '';
637
+ img.style.height = '';
638
+ img.style.maxWidth = '';
639
+ img.style.maxHeight = '';
640
+ img.style.transform = '';
641
+ const rect = img.getBoundingClientRect();
642
+ img.style.width = prev.width;
643
+ img.style.height = prev.height;
644
+ img.style.maxWidth = prev.maxWidth;
645
+ img.style.maxHeight = prev.maxHeight;
646
+ img.style.transform = prev.transform;
647
+ if (!rect.width || !rect.height) return null;
648
+ imgZoom.baseW = rect.width;
649
+ imgZoom.baseH = rect.height;
650
+ return { baseW: rect.width, baseH: rect.height };
651
+ }
652
+
653
+ function _getImgBaseSize() {
654
+ if (imgZoom.baseW && imgZoom.baseH) return { baseW: imgZoom.baseW, baseH: imgZoom.baseH };
655
+ return _measureImgBaseSize();
656
+ }
657
+
658
+ let imgZoomCrispenTimer = null;
659
+
660
+ function _cancelImgZoomCrispen() {
661
+ if (!imgZoomCrispenTimer) return;
662
+ clearTimeout(imgZoomCrispenTimer);
663
+ imgZoomCrispenTimer = null;
664
+ }
665
+
666
+ // Re-layout only once the gesture stops, so a wheel spin does not trigger a
667
+ // full resample of a large bitmap on every tick.
668
+ function _scheduleImgZoomCrispen() {
669
+ _cancelImgZoomCrispen();
670
+ imgZoomCrispenTimer = setTimeout(() => {
671
+ imgZoomCrispenTimer = null;
672
+ if (!imgZoom.container) return;
673
+ _applyImgZoom({ crisp: true });
674
+ }, 180);
675
+ }
676
+
677
+ function _recomputeImgZoomMinScale({ resetToMin = false } = {}) {
678
+ if (!imgZoom.container) return;
679
+ const cW = imgZoom.container.clientWidth;
680
+ const cH = imgZoom.container.clientHeight;
681
+ const size = _getImgBaseSize();
682
+ if (!cW || !cH || !size || !size.baseW || !size.baseH) return;
683
+ const fitScaleX = cW / size.baseW;
684
+ const fitScaleY = cH / size.baseH;
685
+ const nextMinScale = Math.max(IMG_ZOOM_MIN_FLOOR, Math.min(1, fitScaleX, fitScaleY));
686
+ const wasAtMin = _isAtImgZoomMin();
687
+ imgZoom.minScale = nextMinScale;
688
+ if (resetToMin || wasAtMin || imgZoom.scale < imgZoom.minScale) {
689
+ imgZoom.scale = imgZoom.minScale;
690
+ imgZoom.panX = 0;
691
+ imgZoom.panY = 0;
692
+ }
693
+ _clampImgZoom();
694
+ _applyImgZoom();
695
+ }
696
+
697
+ /**
698
+ * Zooming happens in two stages.
699
+ *
700
+ * While the user is actively zooming, a CSS transform is used: it is GPU cheap
701
+ * and stays smooth even on an 8-megapixel screenshot. Once the gesture settles,
702
+ * the image is re-laid-out at its zoomed size instead, which forces the browser
703
+ * to resample from the full-resolution bitmap.
704
+ *
705
+ * The second stage matters because a transform only magnifies whatever raster
706
+ * the compositor already has. Desktop Chromium happens to re-rasterise and stays
707
+ * sharp, but that is not guaranteed — devices that cap decoded image resolution
708
+ * to save memory, which is common on phones, keep magnifying the reduced raster
709
+ * and fine text turns to mush.
710
+ */
711
+ function _applyImgZoom({ crisp = false } = {}) {
712
+ const img = imgZoom.container?.querySelector('img');
713
+ if (!img) return;
714
+
715
+ const size = _getImgBaseSize();
716
+ const atRest = Math.abs(imgZoom.scale - imgZoom.minScale) <= IMG_ZOOM_EPS
717
+ && imgZoom.panX === 0 && imgZoom.panY === 0;
718
+ const panOnly = imgZoom.panX === 0 && imgZoom.panY === 0
719
+ ? ''
720
+ : `translate(${imgZoom.panX}px,${imgZoom.panY}px)`;
721
+
722
+ if (crisp && size && !atRest) {
723
+ img.style.maxWidth = 'none';
724
+ img.style.maxHeight = 'none';
725
+ img.style.width = `${size.baseW * imgZoom.scale}px`;
726
+ img.style.height = `${size.baseH * imgZoom.scale}px`;
727
+ img.style.transform = panOnly;
728
+ } else {
729
+ img.style.maxWidth = '';
730
+ img.style.maxHeight = '';
731
+ img.style.width = '';
732
+ img.style.height = '';
733
+ img.style.transform = atRest
734
+ ? ''
735
+ : `${panOnly} scale(${imgZoom.scale})`.trim();
736
+ _scheduleImgZoomCrispen();
737
+ }
738
+ imgZoom.container.style.cursor = imgZoom.dragging ? 'grabbing'
739
+ : imgZoom.scale > (imgZoom.minScale + IMG_ZOOM_EPS) ? 'grab' : 'zoom-in';
740
+ }
741
+
742
+ function _clampImgZoom() {
743
+ imgZoom.scale = Math.max(imgZoom.minScale, Math.min(IMG_ZOOM_MAX, imgZoom.scale));
744
+ const size = _getImgBaseSize();
745
+ if (size && imgZoom.container) {
746
+ const cW = imgZoom.container.clientWidth;
747
+ const cH = imgZoom.container.clientHeight;
748
+ const scaledW = size.baseW * imgZoom.scale;
749
+ const scaledH = size.baseH * imgZoom.scale;
750
+ const maxPanX = Math.max(0, (scaledW - cW) / 2);
751
+ const maxPanY = Math.max(0, (scaledH - cH) / 2);
752
+ imgZoom.panX = Math.max(-maxPanX, Math.min(maxPanX, imgZoom.panX));
753
+ imgZoom.panY = Math.max(-maxPanY, Math.min(maxPanY, imgZoom.panY));
754
+ }
755
+ if (imgZoom.scale <= imgZoom.minScale + IMG_ZOOM_EPS) {
756
+ imgZoom.scale = imgZoom.minScale;
757
+ imgZoom.panX = 0;
758
+ imgZoom.panY = 0;
759
+ }
760
+ }
761
+
762
+ function _zoomAtPoint(factor, cxFromCenter, cyFromCenter) {
763
+ const old = imgZoom.scale;
764
+ imgZoom.scale = old * factor;
765
+ if (imgZoom.scale !== old) {
766
+ imgZoom.panX = cxFromCenter - (cxFromCenter - imgZoom.panX) * (imgZoom.scale / old);
767
+ imgZoom.panY = cyFromCenter - (cyFromCenter - imgZoom.panY) * (imgZoom.scale / old);
768
+ }
769
+ _clampImgZoom();
770
+ _applyImgZoom();
771
+ }
772
+
773
+ function _ptFromCenter(clientX, clientY) {
774
+ const r = imgZoom.container.getBoundingClientRect();
775
+ return { cx: clientX - r.left - r.width / 2, cy: clientY - r.top - r.height / 2 };
776
+ }
777
+
778
+ function _imgZoomWheel(e) {
779
+ e.preventDefault();
780
+ const { cx, cy } = _ptFromCenter(e.clientX, e.clientY);
781
+ _zoomAtPoint(e.deltaY < 0 ? 1.15 : 1 / 1.15, cx, cy);
782
+ }
783
+
784
+ function _imgZoomMouseDown(e) {
785
+ if (e.button !== 0 || _isAtImgZoomMin()) return;
786
+ e.preventDefault();
787
+ imgZoom.dragging = true;
788
+ imgZoom.lastX = e.clientX;
789
+ imgZoom.lastY = e.clientY;
790
+ _applyImgZoom();
791
+ const onMove = (ev) => {
792
+ if (!imgZoom.dragging) return;
793
+ imgZoom.panX += ev.clientX - imgZoom.lastX;
794
+ imgZoom.panY += ev.clientY - imgZoom.lastY;
795
+ imgZoom.lastX = ev.clientX;
796
+ imgZoom.lastY = ev.clientY;
797
+ _clampImgZoom();
798
+ _applyImgZoom();
799
+ };
800
+ const onUp = () => {
801
+ imgZoom.dragging = false;
802
+ _applyImgZoom();
803
+ document.removeEventListener('mousemove', onMove);
804
+ document.removeEventListener('mouseup', onUp);
805
+ };
806
+ document.addEventListener('mousemove', onMove);
807
+ document.addEventListener('mouseup', onUp);
808
+ }
809
+
810
+ function _imgZoomDblClick(e) {
811
+ const { cx, cy } = _ptFromCenter(e.clientX, e.clientY);
812
+ if (!_isAtImgZoomMin() || imgZoom.panX !== 0 || imgZoom.panY !== 0) {
813
+ imgZoom.scale = imgZoom.minScale;
814
+ imgZoom.panX = 0;
815
+ imgZoom.panY = 0;
816
+ _applyImgZoom();
817
+ } else {
818
+ _zoomAtPoint(3, cx, cy);
819
+ }
820
+ }
821
+
822
+ function _imgZoomTouchStart(e) {
823
+ const now = Date.now();
824
+ if (e.touches.length === 2) {
825
+ e.preventDefault();
826
+ imgZoom.dragging = false;
827
+ imgZoom.pinching = true;
828
+ const t0 = e.touches[0], t1 = e.touches[1];
829
+ imgZoom.pinchDist0 = Math.hypot(t0.clientX - t1.clientX, t0.clientY - t1.clientY);
830
+ imgZoom.pinchScale0 = imgZoom.scale;
831
+ imgZoom.pinchPanX0 = imgZoom.panX;
832
+ imgZoom.pinchPanY0 = imgZoom.panY;
833
+ const r = imgZoom.container.getBoundingClientRect();
834
+ imgZoom.pinchCX = (t0.clientX + t1.clientX) / 2 - r.left - r.width / 2;
835
+ imgZoom.pinchCY = (t0.clientY + t1.clientY) / 2 - r.top - r.height / 2;
836
+ } else if (e.touches.length === 1) {
837
+ if (now - imgZoom.lastTapMs < 300) {
838
+ e.preventDefault();
839
+ const { cx, cy } = _ptFromCenter(e.touches[0].clientX, e.touches[0].clientY);
840
+ if (!_isAtImgZoomMin() || imgZoom.panX !== 0 || imgZoom.panY !== 0) {
841
+ imgZoom.scale = imgZoom.minScale;
842
+ imgZoom.panX = 0;
843
+ imgZoom.panY = 0;
844
+ _applyImgZoom();
845
+ } else {
846
+ _zoomAtPoint(3, cx, cy);
847
+ }
848
+ imgZoom.lastTapMs = 0;
849
+ return;
850
+ }
851
+ imgZoom.lastTapMs = now;
852
+ if (!_isAtImgZoomMin()) {
853
+ e.preventDefault();
854
+ imgZoom.dragging = true;
855
+ imgZoom.lastX = e.touches[0].clientX;
856
+ imgZoom.lastY = e.touches[0].clientY;
857
+ }
858
+ }
859
+ }
860
+
861
+ function _imgZoomTouchMove(e) {
862
+ if (imgZoom.pinching && e.touches.length >= 2) {
863
+ e.preventDefault();
864
+ const t0 = e.touches[0], t1 = e.touches[1];
865
+ const dist = Math.hypot(t0.clientX - t1.clientX, t0.clientY - t1.clientY);
866
+ const newScale = Math.max(imgZoom.minScale, Math.min(IMG_ZOOM_MAX, imgZoom.pinchScale0 * (dist / imgZoom.pinchDist0)));
867
+ imgZoom.scale = newScale;
868
+ imgZoom.panX = imgZoom.pinchCX - (imgZoom.pinchCX - imgZoom.pinchPanX0) * (newScale / imgZoom.pinchScale0);
869
+ imgZoom.panY = imgZoom.pinchCY - (imgZoom.pinchCY - imgZoom.pinchPanY0) * (newScale / imgZoom.pinchScale0);
870
+ _clampImgZoom();
871
+ _applyImgZoom();
872
+ } else if (imgZoom.dragging && e.touches.length === 1) {
873
+ e.preventDefault();
874
+ imgZoom.panX += e.touches[0].clientX - imgZoom.lastX;
875
+ imgZoom.panY += e.touches[0].clientY - imgZoom.lastY;
876
+ imgZoom.lastX = e.touches[0].clientX;
877
+ imgZoom.lastY = e.touches[0].clientY;
878
+ _clampImgZoom();
879
+ _applyImgZoom();
880
+ }
881
+ }
882
+
883
+ function _imgZoomTouchEnd(e) {
884
+ if (e.touches.length < 2) imgZoom.pinching = false;
885
+ if (e.touches.length === 0) {
886
+ imgZoom.dragging = false;
887
+ _applyImgZoom();
888
+ }
889
+ }
890
+
891
+ function _imgZoomOnResize() {
892
+ if (!imgZoom.container) return;
893
+ // The unzoomed size is container-relative, so it has to be measured again.
894
+ imgZoom.baseW = 0;
895
+ imgZoom.baseH = 0;
896
+ _recomputeImgZoomMinScale({ resetToMin: _isAtImgZoomMin() });
897
+ }
898
+
899
+ export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options = {}) {
900
+ const isImage = String(mimeType || '').startsWith('image/');
901
+ const isVideo = isVideoMimeType(mimeType);
902
+ const viewerOptions = normalizeVideoPreviewOptions(options);
903
+ setFilePreviewState({
904
+ path: String(name || 'attachment'),
905
+ source: 'upload',
906
+ mode: 'preview',
907
+ allowHtml: false,
908
+ loading: false,
909
+ error: '',
910
+ viewerOptions,
911
+ payload: {
912
+ kind: isImage ? 'image' : (isVideo ? 'video' : 'binary'),
913
+ name: String(name || 'attachment'),
914
+ rawUrl: String(contentUrl || ''),
915
+ size: 0,
916
+ contentType: String(mimeType || '').toLowerCase(),
917
+ },
918
+ });
919
+ const modal = document.getElementById('file-preview-modal');
920
+ modal.classList.add('visible');
921
+ modal.setAttribute('aria-hidden', 'false');
922
+ renderFilePreview();
923
+ }
924
+
925
+ export function setFilePreviewMode(mode) {
926
+ const nextMode = String(mode || '').toLowerCase();
927
+ if (nextMode !== 'preview' && nextMode !== 'raw') return;
928
+ filePreviewState.mode = nextMode;
929
+ renderFilePreview();
930
+ }
931
+
932
+ export function toggleFilePreviewHtml() {
933
+ if (filePreviewState.payload?.kind !== 'markdown') return;
934
+ filePreviewState.allowHtml = !filePreviewState.allowHtml;
935
+ renderFilePreview();
936
+ }
937
+
938
+ export function renderFilePreview() {
939
+ teardownImageZoom();
940
+ teardownVideoPreview();
941
+ const titleEl = document.getElementById('file-preview-title');
942
+ const metaEl = document.getElementById('file-preview-meta');
943
+ const bodyEl = document.getElementById('file-preview-body');
944
+ const rawLink = document.getElementById('file-preview-open-raw');
945
+ const payload = filePreviewState.payload;
946
+ const rawHref = filePreviewState.source === 'drives'
947
+ || filePreviewState.source === 'session'
948
+ ? driveFileHrefFromPath(filePreviewState.path)
949
+ : filePreviewState.source === 'upload'
950
+ ? String(payload?.rawUrl || '')
951
+ : `${BASE}/api/files/${filePreviewState.path.split('/').map((s) => encodeURIComponent(s)).join('/')}${currentWorkspaceScopeSuffix()}`;
952
+ rawLink.href = rawHref || '#';
953
+ const fallbackName = String(filePreviewState.path || '').split('/').filter(Boolean).pop() || 'download';
954
+ rawLink.setAttribute('download', String(payload?.name || fallbackName));
955
+ rawLink.textContent = 'Download';
956
+ rawLink.setAttribute('title', 'Download file');
957
+
958
+ const titlePath = filePreviewState.path || '';
959
+ titleEl.textContent = titlePath || 'File preview';
960
+ const fileReferenceToken = titlePath ? buildReferenceToken('file', titlePath, filePreviewState.source) : '';
961
+ if (fileReferenceToken) {
962
+ titleEl.setAttribute('data-copy-reference', fileReferenceToken);
963
+ titleEl.setAttribute('title', `Click to copy \`${fileReferenceToken}\``);
964
+ titleEl.classList.add('file-preview-title-copyable');
965
+ } else {
966
+ titleEl.removeAttribute('data-copy-reference');
967
+ titleEl.removeAttribute('title');
968
+ titleEl.classList.remove('file-preview-title-copyable');
969
+ }
970
+ updateFilePreviewUiState();
971
+
972
+ if (filePreviewState.loading) {
973
+ metaEl.textContent = 'Loading preview...';
974
+ bodyEl.innerHTML = '<div class="file-preview-note">Fetching file preview…</div>';
975
+ return;
976
+ }
977
+ if (filePreviewState.error) {
978
+ metaEl.textContent = 'Preview failed';
979
+ bodyEl.innerHTML = `<pre class="file-preview-code"><code>${escHtml(filePreviewState.error)}</code></pre>`;
980
+ return;
981
+ }
982
+ if (!payload) {
983
+ metaEl.textContent = 'No preview data';
984
+ bodyEl.innerHTML = '<div class="file-preview-note">No preview available.</div>';
985
+ return;
986
+ }
987
+
988
+ const kindLabel = String(payload.kind || 'text').toUpperCase();
989
+ const langLabel = payload.language ? ` · ${payload.language}` : '';
990
+ const truncatedLabel = payload.truncated ? ` · truncated to ${formatBytes(FILE_PREVIEW_MAX_BYTES)}` : '';
991
+ metaEl.textContent = `${kindLabel}${langLabel} · ${formatBytes(payload.size || 0)}${truncatedLabel}`;
992
+
993
+ if (payload.kind === 'binary') {
994
+ bodyEl.innerHTML = '<div class="file-preview-note">Binary file preview is not shown. Use <b>Download</b> to save the file.</div>';
995
+ return;
996
+ }
997
+
998
+ if (payload.kind === 'image') {
999
+ const imageHref = filePreviewState.source === 'upload'
1000
+ ? String(payload.rawUrl || '')
1001
+ : String(rawHref || payload.rawUrl || '');
1002
+ if (imageHref) {
1003
+ bodyEl.innerHTML = `<div class="file-preview-image"><img loading="lazy" src="${escHtml(imageHref)}" alt="${escHtml(payload.name || filePreviewState.path || 'image')}"></div>`;
1004
+ bodyEl.classList.add('image-zoom-mode');
1005
+ setupImageZoom(bodyEl.querySelector('.file-preview-image'));
1006
+ } else {
1007
+ bodyEl.innerHTML = '<div class="file-preview-note">Image preview unavailable.</div>';
1008
+ }
1009
+ return;
1010
+ }
1011
+
1012
+ if (filePreviewState.mode === 'raw') {
1013
+ bodyEl.innerHTML = payload.kind === 'video'
1014
+ ? '<div class="file-preview-note">Video files are binary. Use <b>Download</b> to save the file.</div>'
1015
+ : `<div class="file-preview-code"><pre><code>${escHtml(String(payload.content || ''))}</code></pre></div>`;
1016
+ return;
1017
+ }
1018
+
1019
+ if (payload.kind === 'video') {
1020
+ const videoHref = String(rawHref || payload.rawUrl || '');
1021
+ const viewerOptions = filePreviewState.viewerOptions || {};
1022
+ const startSeconds = Math.max(0, Number(viewerOptions.startSeconds || 0) || 0);
1023
+ const preload = String(viewerOptions.preload || 'metadata').toLowerCase() === 'auto' ? 'auto' : 'metadata';
1024
+ const autoplay = viewerOptions.autoplay === true;
1025
+ if (videoHref) {
1026
+ bodyEl.innerHTML = `
1027
+ <div class="file-preview-video-shell" data-start-seconds="${escHtml(String(startSeconds))}" data-preload="${escHtml(preload)}">
1028
+ <video class="file-preview-video" controls playsinline preload="${escHtml(preload)}" src="${escHtml(videoHref)}"></video>
1029
+ <div class="file-preview-note">${startSeconds > 0 ? `Will start at ${startSeconds.toFixed(2)}s.` : 'Video preview ready.'} ${preload === 'auto' ? 'Preloading enabled.' : 'Metadata preload enabled.'}</div>
1030
+ </div>`;
1031
+ bodyEl.classList.add('video-preview-mode');
1032
+ const shell = bodyEl.querySelector('.file-preview-video-shell');
1033
+ const video = shell?.querySelector('video');
1034
+ if (video) {
1035
+ videoPreview.videoEl = video;
1036
+ videoPreview.onLoadedMetadata = () => {
1037
+ if (startSeconds > 0 && Number.isFinite(video.duration) && video.duration > startSeconds) {
1038
+ try {
1039
+ video.currentTime = startSeconds;
1040
+ } catch {}
1041
+ }
1042
+ if (autoplay) {
1043
+ const playPromise = video.play?.();
1044
+ if (playPromise && typeof playPromise.catch === 'function') {
1045
+ playPromise.catch(() => {});
1046
+ }
1047
+ }
1048
+ };
1049
+ videoPreview.onCanPlay = () => {
1050
+ if (autoplay && video.paused) {
1051
+ const playPromise = video.play?.();
1052
+ if (playPromise && typeof playPromise.catch === 'function') {
1053
+ playPromise.catch(() => {});
1054
+ }
1055
+ }
1056
+ };
1057
+ videoPreview.onError = () => {
1058
+ let errorNote = bodyEl.querySelector('.file-preview-video-error');
1059
+ if (!errorNote) {
1060
+ errorNote = document.createElement('div');
1061
+ errorNote.className = 'file-preview-note file-preview-video-error';
1062
+ bodyEl.appendChild(errorNote);
1063
+ }
1064
+ errorNote.innerHTML = 'Video preview unavailable. Use <b>Download</b> to save the file.';
1065
+ };
1066
+ video.addEventListener('loadedmetadata', videoPreview.onLoadedMetadata, { once: true });
1067
+ video.addEventListener('canplay', videoPreview.onCanPlay);
1068
+ video.addEventListener('error', videoPreview.onError, { once: true });
1069
+ }
1070
+ } else {
1071
+ bodyEl.innerHTML = '<div class="file-preview-note">Video preview unavailable.</div>';
1072
+ }
1073
+ return;
1074
+ }
1075
+
1076
+ const rawText = String(payload.content || '');
1077
+
1078
+ if (payload.kind === 'markdown') {
1079
+ const html = renderMarkdownPreview(rawText, filePreviewState.allowHtml);
1080
+ bodyEl.innerHTML = `<article class="file-preview-markdown">${html}</article>`;
1081
+ const article = bodyEl.querySelector('.file-preview-markdown');
1082
+ rewriteLocalAssetUrlsInNode(article, {
1083
+ preferDrive: filePreviewState.source === 'drives' || filePreviewState.source === 'session',
1084
+ rewriteAnchors: false,
1085
+ });
1086
+ assignMarkdownHeadingIds(article);
1087
+ bodyEl.querySelectorAll('pre code').forEach((block) => hljs.highlightElement(block));
1088
+ attachCodeCopyButtons(bodyEl);
1089
+ const fragment = String(filePreviewState.viewerOptions?.fragment || '');
1090
+ if (fragment) requestAnimationFrame(() => scrollMarkdownPreviewToFragment(fragment));
1091
+ return;
1092
+ }
1093
+
1094
+ const languageClass = payload.language ? `language-${escHtml(payload.language)}` : '';
1095
+ bodyEl.innerHTML = `<div class="file-preview-code"><pre><code class="${languageClass}">${escHtml(rawText)}</code></pre></div>`;
1096
+ bodyEl.querySelectorAll('pre code').forEach((block) => hljs.highlightElement(block));
1097
+ attachCodeCopyButtons(bodyEl);
1098
+ }
1099
+
1100
+ export async function openWorkspaceFilePreview(rawPath, options = {}) {
1101
+ const normalized = normalizeWorkspaceMentionPath(rawPath);
1102
+ if (!normalized) return;
1103
+ const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
1104
+ if (!options?.fromViewerLink) filePreviewHistory.length = 0;
1105
+ setFilePreviewState({
1106
+ path: normalized,
1107
+ source: 'workspace',
1108
+ mode: 'preview',
1109
+ allowHtml: true,
1110
+ loading: true,
1111
+ error: '',
1112
+ payload: null,
1113
+ viewerOptions,
1114
+ });
1115
+ const modal = document.getElementById('file-preview-modal');
1116
+ modal.classList.add('visible');
1117
+ modal.setAttribute('aria-hidden', 'false');
1118
+ renderFilePreview();
1119
+
1120
+ const payload = await loadWorkspaceFilePreview(normalized, currentConversationId());
1121
+ if (!payload || payload.error) {
1122
+ filePreviewState.loading = false;
1123
+ filePreviewState.error = payload?.error || 'Failed to load file preview';
1124
+ renderFilePreview();
1125
+ return false;
1126
+ }
1127
+ filePreviewState.loading = false;
1128
+ filePreviewState.payload = payload;
1129
+ filePreviewState.path = String(payload.path || normalized);
1130
+ filePreviewState.viewerOptions = viewerOptions;
1131
+ renderFilePreview();
1132
+ return true;
1133
+ }
1134
+
1135
+ export async function openDriveFilePreview(rawPath, options = {}) {
1136
+ const normalized = normalizeDriveBrowserPath(rawPath);
1137
+ if (!normalized) return;
1138
+ const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
1139
+ if (!options?.fromViewerLink) filePreviewHistory.length = 0;
1140
+ setFilePreviewState({
1141
+ path: normalized,
1142
+ source: 'drives',
1143
+ mode: 'preview',
1144
+ allowHtml: true,
1145
+ loading: true,
1146
+ error: '',
1147
+ payload: null,
1148
+ viewerOptions,
1149
+ });
1150
+ const modal = document.getElementById('file-preview-modal');
1151
+ modal.classList.add('visible');
1152
+ modal.setAttribute('aria-hidden', 'false');
1153
+ renderFilePreview();
1154
+
1155
+ const payload = await loadDriveFilePreview(normalized);
1156
+ if (!payload || payload.error) {
1157
+ filePreviewState.loading = false;
1158
+ filePreviewState.error = payload?.error || 'Failed to load drive file preview';
1159
+ renderFilePreview();
1160
+ return false;
1161
+ }
1162
+ filePreviewState.loading = false;
1163
+ filePreviewState.payload = payload;
1164
+ filePreviewState.path = String(payload.path || normalized);
1165
+ filePreviewState.source = 'drives';
1166
+ filePreviewState.viewerOptions = viewerOptions;
1167
+ renderFilePreview();
1168
+ return true;
1169
+ }
1170
+
1171
+ export async function openWorkspaceFilePreviewFromRepo(rawPath, options = {}) {
1172
+ if (repoBrowserState.activeRoot !== 'workspace') {
1173
+ await openDriveFilePreview(rawPath, options);
1174
+ return;
1175
+ }
1176
+ await openWorkspaceFilePreview(rawPath, options);
1177
+ }
1178
+
1179
+ export function closeFilePreview() {
1180
+ teardownImageZoom();
1181
+ teardownVideoPreview();
1182
+ const modal = document.getElementById('file-preview-modal');
1183
+ modal.classList.remove('visible');
1184
+ modal.setAttribute('aria-hidden', 'true');
1185
+ filePreviewHistory.length = 0;
1186
+ }
1187
+
1188
+ export function goBackFilePreview() {
1189
+ const previous = filePreviewHistory.pop();
1190
+ if (!previous) return;
1191
+ setFilePreviewState(previous);
1192
+ renderFilePreview();
1193
+ requestAnimationFrame(() => {
1194
+ const bodyEl = document.getElementById('file-preview-body');
1195
+ if (bodyEl) bodyEl.scrollTop = Number(previous.scrollTop || 0);
1196
+ });
1197
+ }
1198
+
1199
+ async function openFilePreviewLink(anchor) {
1200
+ const target = resolveFilePreviewLink(anchor.getAttribute('href'), filePreviewState.path);
1201
+ if (target.kind === 'fragment') {
1202
+ scrollMarkdownPreviewToFragment(target.fragment);
1203
+ return;
1204
+ }
1205
+ if (target.kind !== 'file') return;
1206
+
1207
+ const snapshot = snapshotFilePreviewState();
1208
+ filePreviewHistory.push(snapshot);
1209
+ renderFilePreview();
1210
+ const loaded = filePreviewState.source === 'workspace'
1211
+ ? await openWorkspaceFilePreview(target.path, { fromViewerLink: true, fragment: target.fragment })
1212
+ : await openDriveFilePreview(target.path, { fromViewerLink: true, fragment: target.fragment });
1213
+ if (loaded) return;
1214
+ filePreviewHistory.pop();
1215
+ setFilePreviewState(snapshot);
1216
+ renderFilePreview();
1217
+ }
1218
+
1219
+ export function normalizeRepoPath(pathValue) {
1220
+ if (pathValue === '' || pathValue === null || pathValue === undefined) return '';
1221
+ if (repoBrowserState.activeRoot !== 'workspace') {
1222
+ return normalizeDriveBrowserPath(pathValue);
1223
+ }
1224
+ return normalizeWorkspaceMentionPath(pathValue);
1225
+ }
1226
+
1227
+ export function getRepoBrowserLaunchCwdPath() {
1228
+ const currentPath = String(repoBrowserState.currentPath || '').trim();
1229
+ const activeWorkspaceRoot = currentWorkspaceRootPathForSelection();
1230
+ if (!currentPath) return activeWorkspaceRoot;
1231
+ if (repoBrowserState.activeRoot === 'workspace') {
1232
+ return joinLaunchCwdPath(activeWorkspaceRoot, currentPath);
1233
+ }
1234
+ return normalizeDriveBrowserPath(currentPath) || currentPath.replace(/\\/g, '/');
1235
+ }
1236
+
1237
+ export function repoNodeMapFromTree(root) {
1238
+ const map = new Map();
1239
+ function walk(node) {
1240
+ if (!node || typeof node !== 'object') return;
1241
+ const nodePath = String(node.path || '');
1242
+ map.set(nodePath, node);
1243
+ if (node.type === 'dir' && Array.isArray(node.children)) {
1244
+ for (const child of node.children) walk(child);
1245
+ }
1246
+ }
1247
+ walk(root);
1248
+ return map;
1249
+ }
1250
+
1251
+ export function repoRawHref(pathValue) {
1252
+ if (repoBrowserState.activeRoot !== 'workspace') {
1253
+ return `${BASE}/api/drives/file?path=${encodeURIComponent(String(pathValue || ''))}`;
1254
+ }
1255
+ return `${BASE}/api/files/${String(pathValue || '').split('/').map((segment) => encodeURIComponent(segment)).join('/')}${currentWorkspaceScopeSuffix()}`;
1256
+ }
1257
+
1258
+ export function repoIcon(node) {
1259
+ if (!node || typeof node !== 'object') return '📄';
1260
+ if (node.type === 'dir') {
1261
+ if (node.driveType === 'fixed') return '💽';
1262
+ if (node.driveType === 'removable') return '💾';
1263
+ return '📁';
1264
+ }
1265
+ const kind = String(node.previewKind || '').toLowerCase();
1266
+ if (kind === 'image') return '🖼️';
1267
+ if (kind === 'video') return '🎞️';
1268
+ if (kind === 'markdown') return '📝';
1269
+ if (kind === 'code') return '💻';
1270
+ if (kind === 'binary') return '📦';
1271
+ return '📄';
1272
+ }
1273
+
1274
+ function updateRepoToolbarUi() {
1275
+ const workspaceRootBtn = document.getElementById('repo-root-workspace-btn');
1276
+ const drivesRootBtn = document.getElementById('repo-root-drives-btn');
1277
+ const sessionRootBtn = document.getElementById('repo-root-session-btn');
1278
+ const listBtn = document.getElementById('repo-view-list-btn');
1279
+ const gridBtn = document.getElementById('repo-view-grid-btn');
1280
+ const hiddenBtn = document.getElementById('repo-toggle-hidden-btn');
1281
+ const heavyBtn = document.getElementById('repo-toggle-heavy-btn');
1282
+ if (!workspaceRootBtn || !drivesRootBtn || !sessionRootBtn || !listBtn || !gridBtn || !hiddenBtn || !heavyBtn) {
1283
+ return;
1284
+ }
1285
+ const workspaceRoot = repoBrowserState.activeRoot === 'workspace';
1286
+ const sessionRoot = repoBrowserState.activeRoot === 'session';
1287
+ workspaceRootBtn.classList.toggle('active', workspaceRoot);
1288
+ drivesRootBtn.classList.toggle('active', repoBrowserState.activeRoot === 'drives');
1289
+ sessionRootBtn.classList.toggle('active', sessionRoot);
1290
+ drivesRootBtn.disabled = false;
1291
+ sessionRootBtn.disabled = !repoBrowserState.sessionRootPath;
1292
+ listBtn.classList.toggle('active', repoBrowserState.viewMode === 'list');
1293
+ gridBtn.classList.toggle('active', repoBrowserState.viewMode === 'grid');
1294
+ const hiddenEnabled = workspaceRoot
1295
+ ? repoBrowserState.workspaceIncludeHidden
1296
+ : repoBrowserState.drivesIncludeHidden;
1297
+ hiddenBtn.classList.toggle('active', hiddenEnabled);
1298
+ hiddenBtn.textContent = workspaceRoot
1299
+ ? `Hidden: ${hiddenEnabled ? 'On' : 'Off'}`
1300
+ : `Hidden/System: ${hiddenEnabled ? 'On' : 'Off'}`;
1301
+ heavyBtn.disabled = !workspaceRoot;
1302
+ heavyBtn.classList.toggle('active', workspaceRoot && repoBrowserState.workspaceIncludeHeavy);
1303
+ heavyBtn.textContent = workspaceRoot
1304
+ ? `Heavy: ${repoBrowserState.workspaceIncludeHeavy ? 'On' : 'Off'}`
1305
+ : 'Heavy: n/a';
1306
+ }
1307
+
1308
+ export function renderRepoTreeNode(node) {
1309
+ if (!node || typeof node !== 'object') return '';
1310
+ const nodePath = String(node.path || '');
1311
+ const icon = repoIcon(node);
1312
+ if (node.type === 'dir') {
1313
+ const children = Array.isArray(node.children)
1314
+ ? node.children.filter((child) => child?.type === 'dir')
1315
+ : [];
1316
+ const currentPath = String(repoBrowserState.currentPath || '');
1317
+ const isLinuxRoot = nodePath === '/';
1318
+ const isCollapsed = repoBrowserState.collapsedPaths instanceof Set && repoBrowserState.collapsedPaths.has(nodePath);
1319
+ const isExpanded = repoBrowserState.expandedPaths instanceof Set && repoBrowserState.expandedPaths.has(nodePath);
1320
+ const isOpen = !isCollapsed && (nodePath === ''
1321
+ || isExpanded
1322
+ || currentPath === nodePath
1323
+ || (isLinuxRoot
1324
+ ? currentPath.startsWith('/')
1325
+ : (currentPath && currentPath.startsWith(`${nodePath}/`))));
1326
+ const openAttr = isOpen ? ' open' : '';
1327
+ const loading = !!node.loadingChildren;
1328
+ const lazyUnloaded = !!node.lazy && !node.childrenLoaded;
1329
+ const childrenHtml = loading
1330
+ ? ''
1331
+ : (lazyUnloaded
1332
+ ? '<div class="repo-empty">Expand to load entries…</div>'
1333
+ : children.map(renderRepoTreeNode).join(''));
1334
+ return `<details class="repo-tree-node" data-repo-dir-path="${escHtml(nodePath)}"${openAttr}>
1335
+ <summary class="repo-tree-summary" data-repo-open-dir="${escHtml(nodePath)}">${icon} ${escHtml(node.name || '/')}</summary>
1336
+ <div class="repo-tree-children">${childrenHtml}</div>
1337
+ </details>`;
1338
+ }
1339
+ return '';
1340
+ }
1341
+
1342
+ export function updateRepoTreeSelection() {
1343
+ const currentPath = String(repoBrowserState.currentPath || '');
1344
+ const summaries = document.querySelectorAll('#repo-tree .repo-tree-summary[data-repo-open-dir]');
1345
+ summaries.forEach((el) => {
1346
+ const pathValue = String(el.getAttribute('data-repo-open-dir') || '');
1347
+ el.classList.toggle('active', pathValue === currentPath);
1348
+ });
1349
+ }
1350
+
1351
+ export function syncRepoTreeToCurrentPath(collapseOthers = false) {
1352
+ const currentPath = String(repoBrowserState.currentPath || '');
1353
+ const treeHost = document.getElementById('repo-tree');
1354
+ if (!treeHost) return;
1355
+ const ancestorPaths = new Set(repoAncestorPaths(currentPath));
1356
+ const details = treeHost.querySelectorAll('details.repo-tree-node[data-repo-dir-path]');
1357
+ details.forEach((el) => {
1358
+ const pathValue = String(el.getAttribute('data-repo-dir-path') || '');
1359
+ const isCollapsed = repoBrowserState.collapsedPaths instanceof Set && repoBrowserState.collapsedPaths.has(pathValue);
1360
+ const isExpanded = repoBrowserState.expandedPaths instanceof Set && repoBrowserState.expandedPaths.has(pathValue);
1361
+ if (!isCollapsed && (ancestorPaths.has(pathValue) || isExpanded)) {
1362
+ el.open = true;
1363
+ } else if (collapseOthers) {
1364
+ el.open = false;
1365
+ }
1366
+ });
1367
+ // Reveal the active node only when the selection actually moved (or on an
1368
+ // explicit focus): scrolling on every render yanks the pane back while the
1369
+ // user is scrolling it themselves.
1370
+ if (currentPath === lastTreeScrolledPath && !collapseOthers) return;
1371
+ lastTreeScrolledPath = currentPath;
1372
+ const activeEl = treeHost.querySelector('.repo-tree-summary.active, .repo-tree-file.active');
1373
+ if (activeEl) {
1374
+ activeEl.scrollIntoView({ block: 'nearest' });
1375
+ } else {
1376
+ const activeSummary = treeHost.querySelector(`.repo-tree-summary[data-repo-open-dir="${CSS.escape(currentPath)}"]`);
1377
+ if (activeSummary) activeSummary.scrollIntoView({ block: 'nearest' });
1378
+ }
1379
+ }
1380
+
1381
+ export function focusRepoTree() {
1382
+ if (repoBrowserState.expandedPaths instanceof Set) {
1383
+ repoBrowserState.expandedPaths.clear();
1384
+ }
1385
+ syncRepoTreeToCurrentPath(true);
1386
+ }
1387
+
1388
+ export function renderRepoBreadcrumb() {
1389
+ const rootLabel = repoBrowserState.rootName || 'repo';
1390
+ const pathValue = String(repoBrowserState.currentPath || '');
1391
+ const host = document.getElementById('repo-folder-breadcrumb');
1392
+ if (!host) return;
1393
+ const rootSource = repoBrowserState.activeRoot === 'workspace' ? 'workspace' : 'drives';
1394
+ const parts = pathValue ? pathValue.split('/').filter(Boolean) : [];
1395
+ const chips = [`<button class="repo-crumb" data-repo-nav-dir="">${escHtml(rootLabel)}</button>`];
1396
+ let rolling = '';
1397
+ for (const part of parts) {
1398
+ rolling = rolling ? `${rolling}/${part}` : part;
1399
+ chips.push(`<span>/</span><button class="repo-crumb" data-repo-nav-dir="${escHtml(rolling)}">${escHtml(part)}</button>`);
1400
+ }
1401
+ if (pathValue) {
1402
+ chips.push(
1403
+ `<button class="repo-crumb repo-copy-ref-btn" data-repo-copy-folder="${escHtml(pathValue)}" data-repo-copy-source="${escHtml(rootSource)}" title="Copy folder reference">Copy \`@folder\`</button>`,
1404
+ );
1405
+ }
1406
+ host.innerHTML = chips.join('');
1407
+ }
1408
+
1409
+ export function renderRepoFolder() {
1410
+ const folderHost = document.getElementById('repo-folder');
1411
+ const statusHost = document.getElementById('repo-tree-status');
1412
+ if (!folderHost || !statusHost) return;
1413
+ if (repoBrowserState.loading && !repoBrowserState.tree) {
1414
+ folderHost.innerHTML = '<div class="repo-empty">Loading explorer tree…</div>';
1415
+ statusHost.textContent = 'Loading…';
1416
+ return;
1417
+ }
1418
+ if (repoBrowserState.error) {
1419
+ folderHost.innerHTML = `<div class="repo-empty">${escHtml(repoBrowserState.error)}</div>`;
1420
+ statusHost.textContent = 'Error';
1421
+ return;
1422
+ }
1423
+ const nodeLabel = repoBrowserState.activeRoot === 'workspace' ? 'nodes' : 'entries';
1424
+ statusHost.textContent = `${repoBrowserState.nodeCount || 0} ${nodeLabel}${repoBrowserState.truncated ? ' (truncated)' : ''}`;
1425
+ const currentPath = String(repoBrowserState.currentPath || '');
1426
+ let node = repoBrowserState.nodeMap.get(currentPath) || null;
1427
+ if (!node || node.type !== 'dir') {
1428
+ node = repoBrowserState.nodeMap.get('') || null;
1429
+ }
1430
+ if (!node || node.type !== 'dir') {
1431
+ folderHost.innerHTML = '<div class="repo-empty">No explorer tree available.</div>';
1432
+ return;
1433
+ }
1434
+ if (node.loadingChildren) {
1435
+ folderHost.innerHTML = '<div class="repo-empty">Loading folder entries…</div>';
1436
+ return;
1437
+ }
1438
+ if (node.lazy && !node.childrenLoaded) {
1439
+ // Self-heal instead of dead-ending: kick off the load for the selected
1440
+ // folder. Converges because ensureRepoChildrenLoaded sets loadingChildren
1441
+ // synchronously and always ends with childrenLoaded, even on error.
1442
+ folderHost.innerHTML = '<div class="repo-empty">Loading folder entries…</div>';
1443
+ if (!node.loadingChildren) void ensureRepoChildrenLoaded(String(node.path || ''));
1444
+ return;
1445
+ }
1446
+ const children = Array.isArray(node.children)
1447
+ ? node.children.filter((child) => child?.type !== 'dir')
1448
+ : [];
1449
+ if (!children.length) {
1450
+ folderHost.innerHTML = '<div class="repo-empty">This folder has no files.</div>';
1451
+ return;
1452
+ }
1453
+
1454
+ const isGrid = repoBrowserState.viewMode === 'grid';
1455
+ const wrapperClass = isGrid ? 'repo-folder-grid' : 'repo-folder-list';
1456
+ const rows = children.map((child) => {
1457
+ const childPath = String(child.path || '');
1458
+ const rawHref = repoRawHref(childPath);
1459
+ const icon = repoIcon(child);
1460
+ if (isGrid) {
1461
+ const ext = String(child.ext || '').replace('.', '');
1462
+ const isImage = String(child.previewKind || '').toLowerCase() === 'image' || REPO_IMAGE_EXTENSIONS.has(ext.toLowerCase());
1463
+ const thumb = isImage && rawHref
1464
+ ? `<img loading="lazy" src="${escHtml(rawHref)}" alt="${escHtml(child.name || '')}">`
1465
+ : `<span>${icon}</span>`;
1466
+ const fileMeta = `${String(child.previewKind || 'file')} · ${formatBytes(child.size || 0)}`;
1467
+ return `<div class="repo-card repo-card-clickable" data-repo-open-file="${escHtml(childPath)}">
1468
+ <div class="repo-card-thumb">${thumb}</div>
1469
+ <div class="repo-card-name" title="${escHtml(child.name || childPath)}">${escHtml(child.name || childPath)}</div>
1470
+ <div class="repo-entry-meta">${escHtml(fileMeta)}</div>
1471
+ </div>`;
1472
+ }
1473
+
1474
+ const mainMeta = `${String(child.previewKind || 'file')} · ${formatBytes(child.size || 0)}`;
1475
+ return `<div class="repo-entry-row repo-entry-row-clickable" data-repo-open-file="${escHtml(childPath)}">
1476
+ <div class="repo-entry-main">
1477
+ <span>${icon}</span>
1478
+ <div style="min-width:0">
1479
+ <div class="repo-entry-name" title="${escHtml(child.name || childPath)}">${escHtml(child.name || childPath)}</div>
1480
+ <div class="repo-entry-meta">${escHtml(mainMeta)}</div>
1481
+ </div>
1482
+ </div>
1483
+ </div>`;
1484
+ }).join('');
1485
+
1486
+ const savedScrollTop = folderHost.scrollTop;
1487
+ folderHost.innerHTML = `<div class="${wrapperClass}">${rows}</div>`;
1488
+ folderHost.scrollTop = savedScrollTop;
1489
+ }
1490
+
1491
+ export function renderRepoTree() {
1492
+ const treeHost = document.getElementById('repo-tree');
1493
+ if (!treeHost) return;
1494
+ if (repoBrowserState.loading && !repoBrowserState.tree) {
1495
+ treeHost.innerHTML = '<div class="repo-empty">Loading tree…</div>';
1496
+ return;
1497
+ }
1498
+ if (repoBrowserState.error) {
1499
+ treeHost.innerHTML = `<div class="repo-empty">${escHtml(repoBrowserState.error)}</div>`;
1500
+ return;
1501
+ }
1502
+ const root = repoBrowserState.tree;
1503
+ if (!root) {
1504
+ treeHost.innerHTML = '<div class="repo-empty">Tree unavailable.</div>';
1505
+ return;
1506
+ }
1507
+ // The rebuild resets the pane's scroll to 0; put it back so re-renders
1508
+ // (child loads, refreshes) don't rubber-band a scroll in progress.
1509
+ const savedScrollTop = treeHost.scrollTop;
1510
+ treeHost.innerHTML = renderRepoTreeNode(root);
1511
+ updateRepoTreeSelection();
1512
+ treeHost.scrollTop = savedScrollTop;
1513
+ syncRepoTreeToCurrentPath();
1514
+ }
1515
+
1516
+ export function renderRepoBrowser() {
1517
+ if (repoBrowserRenderSuspended > 0) {
1518
+ repoBrowserRenderDirty = true;
1519
+ return;
1520
+ }
1521
+ const title = document.querySelector('.repo-browser-title');
1522
+ if (title) {
1523
+ title.textContent = repoBrowserState.activeRoot === 'workspace'
1524
+ ? 'Repository Browser'
1525
+ : (repoBrowserState.activeRoot === 'session' ? 'Session Browser' : 'Drives Browser');
1526
+ }
1527
+ updateRepoToolbarUi();
1528
+ renderRepoBreadcrumb();
1529
+ renderRepoTree();
1530
+ renderRepoFolder();
1531
+ updateRepoCwdPickBar();
1532
+ }
1533
+
1534
+ export async function loadRepoBrowserTree() {
1535
+ if (repoBrowserState.loading) {
1536
+ repoBrowserReloadQueued = true;
1537
+ return;
1538
+ }
1539
+ repoBrowserReloadQueued = false;
1540
+ repoBrowserState.loading = true;
1541
+ repoBrowserState.loadingPath = '';
1542
+ repoBrowserState.error = '';
1543
+ renderRepoBrowser();
1544
+
1545
+ const workspaceRoot = repoBrowserState.activeRoot === 'workspace';
1546
+ const sessionRoot = repoBrowserState.activeRoot === 'session';
1547
+ const requestedConversationId = workspaceRoot ? currentConversationId() : '';
1548
+ const payload = workspaceRoot
1549
+ ? await loadRepoTree(repoBrowserState.workspaceIncludeHidden, repoBrowserState.workspaceIncludeHeavy, requestedConversationId)
1550
+ : (sessionRoot
1551
+ ? await loadSessionRootTree(normalizeDriveBrowserPath(repoBrowserState.sessionRootPath), repoBrowserState.drivesIncludeHidden)
1552
+ : await loadDrivesRoots());
1553
+ if (workspaceRoot && requestedConversationId !== currentConversationId()) {
1554
+ repoBrowserState.loading = false;
1555
+ repoBrowserReloadQueued = true;
1556
+ flushQueuedRepoBrowserReload();
1557
+ return;
1558
+ }
1559
+ const rootNode = payload?.root || payload?.node || null;
1560
+ if (!payload || payload.error || !rootNode) {
1561
+ repoBrowserState.loading = false;
1562
+ repoBrowserState.error = payload?.error || (workspaceRoot ? 'Failed to load repository tree.' : (sessionRoot ? 'Failed to load session tree.' : 'Failed to load drives.'));
1563
+ pendingRepoBrowserRestore = null;
1564
+ renderRepoBrowser();
1565
+ flushQueuedRepoBrowserReload();
1566
+ return;
1567
+ }
1568
+
1569
+ repoBrowserState.loading = false;
1570
+ repoBrowserState.rootName = String(payload.rootName || (workspaceRoot ? 'repo' : (sessionRoot ? repoBrowserState.sessionRootName || 'Session' : 'Drives')));
1571
+ repoBrowserState.tree = rootNode;
1572
+ repoBrowserState.nodeMap = repoNodeMapFromTree(rootNode);
1573
+ repoBrowserState.truncated = !!payload.truncated;
1574
+ repoBrowserState.nodeCount = Number(payload.nodeCount || repoBrowserState.nodeMap.size || 0);
1575
+ repoBrowserState.maxNodes = Number(payload.maxNodes || repoBrowserState.nodeMap.size || 0);
1576
+ if (sessionRoot) {
1577
+ repoBrowserState.currentPath = String(rootNode.path || repoBrowserState.sessionRootPath || '');
1578
+ if (rootNode && typeof rootNode === 'object') {
1579
+ rootNode.childrenLoaded = true;
1580
+ rootNode.lazy = false;
1581
+ }
1582
+ } else if (!workspaceRoot && rootNode?.path && repoBrowserState.nodeMap.has(String(rootNode.path))) {
1583
+ repoBrowserState.currentPath = String(rootNode.path);
1584
+ } else if (!repoBrowserState.nodeMap.has(repoBrowserState.currentPath)) {
1585
+ repoBrowserState.currentPath = '';
1586
+ }
1587
+ if (repoBrowserState.expandedPaths instanceof Set) {
1588
+ const selectedPath = String(repoBrowserState.currentPath || '');
1589
+ if (selectedPath) repoBrowserState.expandedPaths.add(selectedPath);
1590
+ }
1591
+ renderRepoBrowser();
1592
+ // Read the queue flag before flushing: flushQueuedRepoBrowserReload re-enters
1593
+ // this function synchronously. When another load is already queued, leave the
1594
+ // restore parked so that load performs it against the tree it actually
1595
+ // fetched — that is what makes a rapid double-toggle safe.
1596
+ const restoreNow = repoBrowserReloadQueued ? null : takePendingRepoBrowserRestore();
1597
+ flushQueuedRepoBrowserReload();
1598
+ if (restoreNow) void applyRepoBrowserRestore(restoreNow);
1599
+ }
1600
+
1601
+ const repoChildrenLoadsInFlight = new Map();
1602
+
1603
+ export async function ensureRepoChildrenLoaded(pathValue) {
1604
+ if (pathValue === null || pathValue === undefined) return false;
1605
+ const normalizedPath = normalizeRepoPath(pathValue);
1606
+ const nodePath = normalizedPath || '';
1607
+ if (!nodePath && repoBrowserState.activeRoot !== 'workspace') return false;
1608
+
1609
+ const node = repoBrowserState.nodeMap.get(nodePath);
1610
+ if (!node || node.type !== 'dir') return false;
1611
+ if (node.childrenLoaded) return true;
1612
+ // Concurrent callers (folder-pane self-heal racing the reopen restore walk)
1613
+ // must share one fetch: returning false here would make the restore silently
1614
+ // skip the branch and drop its descendants.
1615
+ const inFlight = repoChildrenLoadsInFlight.get(nodePath);
1616
+ if (inFlight) return inFlight;
1617
+
1618
+ const load = loadRepoChildrenIntoNode(nodePath, node);
1619
+ repoChildrenLoadsInFlight.set(nodePath, load);
1620
+ try {
1621
+ return await load;
1622
+ } finally {
1623
+ repoChildrenLoadsInFlight.delete(nodePath);
1624
+ }
1625
+ }
1626
+
1627
+ async function loadRepoChildrenIntoNode(nodePath, node) {
1628
+ node.loadingChildren = true;
1629
+ repoBrowserState.loadingPath = nodePath;
1630
+ renderRepoBrowser();
1631
+ const treeAtRequest = repoBrowserState.tree;
1632
+
1633
+ // A rejection must not strand loadingChildren=true: the folder pane would
1634
+ // show "Loading…" forever and the self-heal guard would never refire. Treat
1635
+ // it exactly like an error payload (empty children, readError) below.
1636
+ let payload = null;
1637
+ try {
1638
+ payload = repoBrowserState.activeRoot === 'workspace'
1639
+ ? await loadRepoChildren(
1640
+ nodePath,
1641
+ repoBrowserState.workspaceIncludeHidden,
1642
+ repoBrowserState.workspaceIncludeHeavy,
1643
+ currentConversationId(),
1644
+ )
1645
+ : await loadDriveChildren(nodePath, repoBrowserState.drivesIncludeHidden);
1646
+ } catch {
1647
+ payload = null;
1648
+ }
1649
+
1650
+ node.loadingChildren = false;
1651
+ repoBrowserState.loadingPath = '';
1652
+ // The tree can be swapped out mid-fetch by a reload; writing onto the
1653
+ // orphaned node object would silently drop the children. Attach to the node
1654
+ // that now lives at the same path, or bail if the path is gone.
1655
+ const target = repoBrowserState.tree === treeAtRequest
1656
+ ? node
1657
+ : repoBrowserState.nodeMap.get(nodePath) || null;
1658
+ if (!target || target.type !== 'dir') {
1659
+ renderRepoBrowser();
1660
+ return false;
1661
+ }
1662
+ target.loadingChildren = false;
1663
+ if (!payload || payload.error || !payload.node || !Array.isArray(payload.node.children)) {
1664
+ target.children = [];
1665
+ target.childrenLoaded = true;
1666
+ target.readError = true;
1667
+ renderRepoBrowser();
1668
+ return false;
1669
+ }
1670
+
1671
+ target.children = payload.node.children;
1672
+ target.childrenLoaded = true;
1673
+ target.lazy = false;
1674
+ target.readError = !!payload.node.readError;
1675
+ repoBrowserState.nodeMap = repoNodeMapFromTree(repoBrowserState.tree);
1676
+ repoBrowserState.nodeCount = repoBrowserState.nodeMap.size;
1677
+ renderRepoBrowser();
1678
+ return true;
1679
+ }
1680
+
1681
+ /**
1682
+ * Re-open, on the freshly fetched tree, whatever was open before a refresh.
1683
+ *
1684
+ * The chain is walked sequentially on purpose: ensureRepoChildrenLoaded rebuilds
1685
+ * nodeMap on every success, and a child directory only becomes reachable once
1686
+ * its parent's children have landed.
1687
+ */
1688
+ async function applyRepoBrowserRestore({ path: keepPath = '', seq = 0 } = {}) {
1689
+ const expandedPaths = repoBrowserState.expandedPaths instanceof Set
1690
+ ? [...repoBrowserState.expandedPaths]
1691
+ : [];
1692
+ const plan = planRepoRehydration({ currentPath: keepPath, expandedPaths });
1693
+ const pathAtStart = String(repoBrowserState.currentPath || '');
1694
+
1695
+ await withSuspendedRepoRender(async () => {
1696
+ for (const nodePath of plan) {
1697
+ if (seq !== repoBrowserRefreshSeq) return;
1698
+ if (!nodePath) continue;
1699
+ // A directory that vanished with the filter change is simply unknown to
1700
+ // the new nodeMap, and this returns false without touching the network.
1701
+ await ensureRepoChildrenLoaded(nodePath);
1702
+ }
1703
+ });
1704
+
1705
+ if (seq !== repoBrowserRefreshSeq) return;
1706
+ if (String(repoBrowserState.currentPath || '') !== pathAtStart) {
1707
+ renderRepoBrowser();
1708
+ return;
1709
+ }
1710
+ const resolvedPath = deepestExistingAncestor(keepPath, (candidate) => {
1711
+ const node = repoBrowserState.nodeMap.get(candidate);
1712
+ return !!node && node.type === 'dir';
1713
+ });
1714
+ if (resolvedPath) await setRepoCurrentPath(resolvedPath);
1715
+ else renderRepoBrowser();
1716
+ }
1717
+
1718
+ export async function ensureDriveChildrenLoaded(pathValue) {
1719
+ return ensureRepoChildrenLoaded(pathValue);
1720
+ }
1721
+
1722
+ export async function ensureWorkspaceChildrenLoaded(pathValue) {
1723
+ return ensureRepoChildrenLoaded(pathValue);
1724
+ }
1725
+
1726
+ export function setRepoBrowserRoot(root) {
1727
+ const nextRoot = String(root || '').trim().toLowerCase();
1728
+ if (nextRoot !== 'workspace' && nextRoot !== 'drives' && nextRoot !== 'session') return;
1729
+ if (nextRoot === 'session' && !repoBrowserState.sessionRootPath) return;
1730
+ if (repoBrowserState.activeRoot === nextRoot) return;
1731
+ repoBrowserState.activeRoot = nextRoot;
1732
+ // A root switch invalidates any parked restore: the saved path belongs to the
1733
+ // tree we are leaving. The Set wipes below are correct here for the same reason.
1734
+ pendingRepoBrowserRestore = null;
1735
+ setRepoBrowserState({
1736
+ tree: null,
1737
+ nodeMap: new Map(),
1738
+ expandedPaths: new Set(),
1739
+ collapsedPaths: new Set(),
1740
+ currentPath: '',
1741
+ truncated: false,
1742
+ nodeCount: 0,
1743
+ maxNodes: 0,
1744
+ loadingPath: '',
1745
+ error: '',
1746
+ });
1747
+ renderRepoBrowser();
1748
+ if (repoBrowserState.open) {
1749
+ void loadRepoBrowserTree();
1750
+ }
1751
+ }
1752
+
1753
+ export function setRepoBrowserSessionInfo(sessionRootPath, sessionRootName = '') {
1754
+ const nextPath = normalizeDriveBrowserPath(sessionRootPath);
1755
+ const nextName = String(sessionRootName || '').trim() || 'Session';
1756
+ const pathChanged = repoBrowserState.sessionRootPath !== nextPath;
1757
+ repoBrowserState.sessionRootPath = nextPath;
1758
+ repoBrowserState.sessionRootName = nextName;
1759
+ const sessionRootActive = repoBrowserState.activeRoot === 'session';
1760
+ if (pathChanged && sessionRootActive) {
1761
+ pendingRepoBrowserRestore = null;
1762
+ setRepoBrowserState({
1763
+ tree: null,
1764
+ nodeMap: new Map(),
1765
+ expandedPaths: new Set(),
1766
+ collapsedPaths: new Set(),
1767
+ currentPath: '',
1768
+ truncated: false,
1769
+ nodeCount: 0,
1770
+ maxNodes: 0,
1771
+ loadingPath: '',
1772
+ error: '',
1773
+ });
1774
+ }
1775
+ if (!nextPath && sessionRootActive) {
1776
+ repoBrowserState.activeRoot = 'workspace';
1777
+ if (repoBrowserState.open) {
1778
+ void loadRepoBrowserTree();
1779
+ return;
1780
+ }
1781
+ renderRepoBrowser();
1782
+ return;
1783
+ }
1784
+ if (repoBrowserState.open) {
1785
+ if (sessionRootActive && pathChanged) {
1786
+ void loadRepoBrowserTree();
1787
+ return;
1788
+ }
1789
+ renderRepoBrowser();
1790
+ return;
1791
+ }
1792
+ if (sessionRootActive) {
1793
+ renderRepoBrowser();
1794
+ }
1795
+ }
1796
+
1797
+ // Non-null while the browser is acting as a CWD picker for the New chat /
1798
+ // Change CWD modals. Cleared on every close so a later plain open never
1799
+ // resurrects the pick bar.
1800
+ let repoBrowserCwdPickHandler = null;
1801
+
1802
+ function repoBrowserCwdPickModeActive() {
1803
+ return typeof repoBrowserCwdPickHandler === 'function';
1804
+ }
1805
+
1806
+ /**
1807
+ * The picked path, as opposed to the launch path: `getRepoBrowserLaunchCwdPath`
1808
+ * falls back to the conversation's workspace root when nothing is selected,
1809
+ * which is right for the known-CWD list but wrong for a picker — on the drives
1810
+ * root it would offer (and enable confirming) a path from a different tree.
1811
+ * Only the workspace root treats an empty selection as a pick, because there
1812
+ * the visible tree root IS that path.
1813
+ */
1814
+ function getRepoBrowserPickedCwdPath() {
1815
+ const currentPath = String(repoBrowserState.currentPath || '').trim();
1816
+ if (!currentPath && repoBrowserState.activeRoot !== 'workspace') return '';
1817
+ return String(getRepoBrowserLaunchCwdPath() || '').trim();
1818
+ }
1819
+
1820
+ function updateRepoCwdPickBar() {
1821
+ const bar = document.getElementById('repo-cwd-pick-bar');
1822
+ if (!bar) return;
1823
+ const active = repoBrowserCwdPickModeActive();
1824
+ bar.hidden = !active;
1825
+ if (!active) return;
1826
+ const pathEl = document.getElementById('repo-cwd-pick-path');
1827
+ const confirmBtn = document.getElementById('repo-cwd-pick-confirm');
1828
+ const pickedPath = getRepoBrowserPickedCwdPath();
1829
+ if (pathEl) pathEl.textContent = pickedPath || 'Select a folder…';
1830
+ if (confirmBtn) confirmBtn.disabled = !pickedPath;
1831
+ }
1832
+
1833
+ /**
1834
+ * Open the browser as a folder picker: the pick bar appears, the modal stacks
1835
+ * above the summary/new-chat modals, and the global (drives) tree is the
1836
+ * default root — the root tabs stay live, so workspace/session picks work too.
1837
+ */
1838
+ export function openRepoBrowserForCwdPick(onPick) {
1839
+ repoBrowserCwdPickHandler = typeof onPick === 'function' ? onPick : null;
1840
+ const modal = document.getElementById('repo-browser-modal');
1841
+ modal.classList.add('cwd-pick-mode');
1842
+ if (repoBrowserState.activeRoot !== 'drives') setRepoBrowserRoot('drives');
1843
+ openRepoBrowser();
1844
+ updateRepoCwdPickBar();
1845
+ }
1846
+
1847
+ export function confirmRepoBrowserCwdPick() {
1848
+ const handler = repoBrowserCwdPickHandler;
1849
+ if (!handler) return;
1850
+ const pickedPath = getRepoBrowserPickedCwdPath();
1851
+ if (!pickedPath) return;
1852
+ closeRepoBrowser();
1853
+ handler(pickedPath);
1854
+ }
1855
+
1856
+ export function openRepoBrowser() {
1857
+ const modal = document.getElementById('repo-browser-modal');
1858
+ modal.classList.add('visible');
1859
+ modal.setAttribute('aria-hidden', 'false');
1860
+ repoBrowserState.open = true;
1861
+ if (repoBrowserState.activeRoot === 'workspace') {
1862
+ // Reopening refetches the lazy workspace root, which would otherwise strand
1863
+ // the surviving expansion/selection on unloaded placeholder nodes. Park a
1864
+ // restore exactly like Refresh does — but keep the previous tree on screen
1865
+ // while the refetch is in flight, so reopening never flashes a loader.
1866
+ // An already-parked restore (an in-flight Refresh wiped currentPath to '')
1867
+ // still holds the real selection; parking over it would lose the path.
1868
+ if (!pendingRepoBrowserRestore) parkRepoBrowserRestore();
1869
+ void loadRepoBrowserTree();
1870
+ } else if (!repoBrowserState.tree) {
1871
+ void loadRepoBrowserTree();
1872
+ } else {
1873
+ renderRepoBrowser();
1874
+ }
1875
+ }
1876
+
1877
+ export function closeRepoBrowser() {
1878
+ const modal = document.getElementById('repo-browser-modal');
1879
+ modal.classList.remove('visible');
1880
+ modal.classList.remove('cwd-pick-mode');
1881
+ modal.setAttribute('aria-hidden', 'true');
1882
+ repoBrowserState.open = false;
1883
+ repoBrowserCwdPickHandler = null;
1884
+ }
1885
+
1886
+ export function refreshRepoBrowser() {
1887
+ // expandedPaths / collapsedPaths deliberately survive: a refresh refetches the
1888
+ // same root, so the user's expansion is still meaningful. applyRepoBrowserRestore
1889
+ // re-walks it once the new tree arrives.
1890
+ parkRepoBrowserRestore();
1891
+ setRepoBrowserState({
1892
+ tree: null,
1893
+ nodeMap: new Map(),
1894
+ currentPath: '',
1895
+ loadingPath: '',
1896
+ error: '',
1897
+ });
1898
+ void loadRepoBrowserTree();
1899
+ }
1900
+
1901
+ export function refreshRepoBrowserIfWorkspaceOpen() {
1902
+ if (!repoBrowserState.open || repoBrowserState.activeRoot !== 'workspace') return;
1903
+ refreshRepoBrowser();
1904
+ }
1905
+
1906
+ export function resetWorkspaceRepoBrowserForRootChange() {
1907
+ // A CWD change repoints the workspace root, so every cached path (tree,
1908
+ // selection, expansion) belongs to a directory the browser no longer shows.
1909
+ // Dropping them is what stops the explorer from rendering the old CWD after a
1910
+ // relaunch.
1911
+ if (repoBrowserState.activeRoot !== 'workspace') return;
1912
+ pendingRepoBrowserRestore = null;
1913
+ repoBrowserState.expandedPaths = new Set();
1914
+ repoBrowserState.collapsedPaths = new Set();
1915
+ setRepoBrowserState({
1916
+ tree: null,
1917
+ nodeMap: new Map(),
1918
+ currentPath: '',
1919
+ loadingPath: '',
1920
+ error: '',
1921
+ rootName: 'repo',
1922
+ });
1923
+ if (repoBrowserState.open) {
1924
+ void loadRepoBrowserTree();
1925
+ } else {
1926
+ renderRepoBrowser();
1927
+ }
1928
+ }
1929
+
1930
+ export function setRepoBrowserViewMode(mode) {
1931
+ const value = String(mode || '').toLowerCase();
1932
+ if (value !== 'list' && value !== 'grid') return;
1933
+ repoBrowserState.viewMode = value;
1934
+ renderRepoFolder();
1935
+ updateRepoToolbarUi();
1936
+ }
1937
+
1938
+ export function toggleRepoBrowserHidden() {
1939
+ const nextValue = repoBrowserState.activeRoot === 'workspace'
1940
+ ? !repoBrowserState.workspaceIncludeHidden
1941
+ : !repoBrowserState.drivesIncludeHidden;
1942
+ if (repoBrowserState.activeRoot === 'workspace') {
1943
+ repoBrowserState.workspaceIncludeHidden = nextValue;
1944
+ } else {
1945
+ repoBrowserState.drivesIncludeHidden = nextValue;
1946
+ }
1947
+ writeRepoBrowserHiddenPreference(repoBrowserState.activeRoot, nextValue);
1948
+ refreshRepoBrowser();
1949
+ }
1950
+
1951
+ export function toggleRepoBrowserHeavy() {
1952
+ if (repoBrowserState.activeRoot !== 'workspace') return;
1953
+ repoBrowserState.workspaceIncludeHeavy = !repoBrowserState.workspaceIncludeHeavy;
1954
+ writeRepoBrowserHeavyPreference(repoBrowserState.workspaceIncludeHeavy);
1955
+ refreshRepoBrowser();
1956
+ }
1957
+
1958
+ export async function setRepoCurrentPath(pathValue) {
1959
+ const rawValue = String(pathValue || '').replace(/\\/g, '/').trim();
1960
+ const normalized = normalizeRepoPath(rawValue);
1961
+ const targetPath = repoBrowserState.nodeMap.has(rawValue)
1962
+ ? rawValue
1963
+ : (normalized || '');
1964
+ const node = repoBrowserState.nodeMap.get(targetPath);
1965
+ if (!node || node.type !== 'dir') return;
1966
+ if (repoBrowserState.expandedPaths instanceof Set) {
1967
+ for (const ancestor of repoAncestorPaths(targetPath)) {
1968
+ if (ancestor) repoBrowserState.expandedPaths.add(ancestor);
1969
+ }
1970
+ }
1971
+ if (repoBrowserState.collapsedPaths instanceof Set) {
1972
+ repoBrowserState.collapsedPaths.delete(targetPath);
1973
+ }
1974
+ repoBrowserState.currentPath = targetPath;
1975
+ await ensureRepoChildrenLoaded(targetPath);
1976
+ renderRepoBreadcrumb();
1977
+ renderRepoFolder();
1978
+ updateRepoTreeSelection();
1979
+ syncRepoTreeToCurrentPath();
1980
+ updateRepoCwdPickBar();
1981
+ }
1982
+
1983
+ document.addEventListener('click', (event) => {
1984
+ const anchor = eventClosest(event, 'a.workspace-file-link[data-workspace-path]');
1985
+ if (!anchor) return;
1986
+ event.preventDefault();
1987
+ void openWorkspaceFilePreview(anchor.dataset.workspacePath || '');
1988
+ });
1989
+
1990
+ document.getElementById('file-preview-modal').addEventListener('click', (event) => {
1991
+ if (event.target.id === 'file-preview-modal') closeFilePreview();
1992
+ });
1993
+
1994
+ document.getElementById('file-preview-body').addEventListener('click', (event) => {
1995
+ const anchor = eventClosest(event, '.file-preview-markdown a[href]');
1996
+ if (!anchor) return;
1997
+ const target = resolveFilePreviewLink(anchor.getAttribute('href'), filePreviewState.path);
1998
+ if (target.kind === 'external') {
1999
+ event.preventDefault();
2000
+ openExternalNavigation(anchor.href, (url) => {
2001
+ window.dispatchEvent(new CustomEvent('copilot:external-link-fallback', { detail: { url } }));
2002
+ });
2003
+ return;
2004
+ }
2005
+ event.preventDefault();
2006
+ void openFilePreviewLink(anchor);
2007
+ });
2008
+
2009
+ document.getElementById('summary-modal').addEventListener('click', (event) => {
2010
+ if (event.target.id === 'summary-modal') window.closeSummaryModal?.();
2011
+ });
2012
+
2013
+ document.getElementById('file-preview-title').addEventListener('click', async (event) => {
2014
+ const target = event.currentTarget;
2015
+ const token = String(target?.getAttribute('data-copy-reference') || '').trim();
2016
+ if (!token) return;
2017
+ event.preventDefault();
2018
+ const wrapped = `\`${token}\``;
2019
+ try {
2020
+ await copyTextToClipboard(wrapped);
2021
+ } catch {}
2022
+ });
2023
+
2024
+ document.getElementById('repo-browser-modal').addEventListener('click', (event) => {
2025
+ if (event.target.id === 'repo-browser-modal') closeRepoBrowser();
2026
+ });
2027
+
2028
+ document.getElementById('repo-tree').addEventListener('click', (event) => {
2029
+ const fileButton = eventClosest(event, '[data-repo-open-file]');
2030
+ if (fileButton) {
2031
+ event.preventDefault();
2032
+ // In pick mode the preview modal (z 11000) would open UNDER the raised
2033
+ // browser (z 12400) — an invisible modal that then swallows Escape. A CWD
2034
+ // picker is about folders; file clicks are inert there.
2035
+ if (!repoBrowserCwdPickModeActive()) {
2036
+ void openWorkspaceFilePreviewFromRepo(fileButton.getAttribute('data-repo-open-file') || '');
2037
+ }
2038
+ return;
2039
+ }
2040
+ const dirSummary = eventClosest(event, '[data-repo-open-dir]');
2041
+ if (dirSummary) {
2042
+ event.preventDefault();
2043
+ const dirPath = dirSummary.getAttribute('data-repo-open-dir') || '';
2044
+ const normalizedDirPath = normalizeRepoPath(dirPath);
2045
+ const targetPath = repoBrowserState.nodeMap.has(dirPath) ? dirPath : (normalizedDirPath || '');
2046
+ const node = repoBrowserState.nodeMap.get(targetPath);
2047
+ const isDir = !!node && node.type === 'dir';
2048
+ const isCurrent = String(repoBrowserState.currentPath || '') === targetPath;
2049
+ const isCollapsed = repoBrowserState.collapsedPaths instanceof Set && repoBrowserState.collapsedPaths.has(targetPath);
2050
+ // A lazy dir whose children never loaded is showing a placeholder, and the
2051
+ // click means "load it" — never "collapse it". Only a loaded current dir
2052
+ // collapse-toggles.
2053
+ const isLoadedDir = isDir && !(node.lazy && !node.childrenLoaded);
2054
+ if (isLoadedDir && isCurrent && !isCollapsed && targetPath) {
2055
+ if (repoBrowserState.expandedPaths instanceof Set) {
2056
+ repoBrowserState.expandedPaths.delete(targetPath);
2057
+ }
2058
+ repoBrowserState.collapsedPaths.add(targetPath);
2059
+ renderRepoTree();
2060
+ return;
2061
+ }
2062
+ if (repoBrowserState.expandedPaths instanceof Set && targetPath) {
2063
+ repoBrowserState.expandedPaths.add(targetPath);
2064
+ }
2065
+ if (repoBrowserState.collapsedPaths instanceof Set) {
2066
+ repoBrowserState.collapsedPaths.delete(targetPath);
2067
+ }
2068
+ void setRepoCurrentPath(dirPath);
2069
+ }
2070
+ });
2071
+
2072
+ document.getElementById('repo-folder').addEventListener('click', (event) => {
2073
+ const navDir = eventClosest(event, '[data-repo-nav-dir]');
2074
+ if (navDir) {
2075
+ event.preventDefault();
2076
+ void setRepoCurrentPath(navDir.getAttribute('data-repo-nav-dir') || '');
2077
+ return;
2078
+ }
2079
+ const openFile = eventClosest(event, '[data-repo-open-file]');
2080
+ if (openFile) {
2081
+ event.preventDefault();
2082
+ // Same pick-mode guard as the tree pane: no invisible preview under the picker.
2083
+ if (!repoBrowserCwdPickModeActive()) {
2084
+ void openWorkspaceFilePreviewFromRepo(openFile.getAttribute('data-repo-open-file') || '');
2085
+ }
2086
+ }
2087
+ });
2088
+
2089
+ document.getElementById('repo-folder-breadcrumb').addEventListener('click', (event) => {
2090
+ const copyFolder = eventClosest(event, '[data-repo-copy-folder]');
2091
+ if (copyFolder) {
2092
+ event.preventDefault();
2093
+ const folderPath = copyFolder.getAttribute('data-repo-copy-folder') || '';
2094
+ const source = copyFolder.getAttribute('data-repo-copy-source') || repoBrowserState.activeRoot;
2095
+ void copyReferenceTokenToClipboard('folder', folderPath, source);
2096
+ return;
2097
+ }
2098
+ const navDir = eventClosest(event, '[data-repo-nav-dir]');
2099
+ if (!navDir) return;
2100
+ event.preventDefault();
2101
+ void setRepoCurrentPath(navDir.getAttribute('data-repo-nav-dir') || '');
2102
+ });
2103
+
2104
+ document.addEventListener('keydown', (event) => {
2105
+ if (event.key !== 'Escape') return;
2106
+ const modal = document.getElementById('file-preview-modal');
2107
+ const repoModal = document.getElementById('repo-browser-modal');
2108
+ const summaryModal = document.getElementById('summary-modal');
2109
+ if (modal.classList.contains('visible')) closeFilePreview();
2110
+ else if (repoModal.classList.contains('visible')) closeRepoBrowser();
2111
+ else if (summaryModal.classList.contains('visible')) window.closeSummaryModal?.();
2112
+ });