@wonderwhy-er/desktop-commander 0.2.38 → 0.2.40

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 (263) hide show
  1. package/README.md +53 -2
  2. package/dist/handlers/filesystem-handlers.d.ts +5 -0
  3. package/dist/handlers/filesystem-handlers.js +14 -2
  4. package/dist/remote-device/desktop-commander-integration.js +1 -1
  5. package/dist/search-manager.js +31 -38
  6. package/dist/server.js +9 -4
  7. package/dist/terminal-manager.js +4 -2
  8. package/dist/tools/edit.js +34 -1
  9. package/dist/tools/filesystem.js +91 -3
  10. package/dist/tools/improved-process-tools.js +2 -1
  11. package/dist/ui/config-editor/config-editor-runtime.js +65 -14096
  12. package/dist/ui/config-editor/styles.css +2 -1
  13. package/dist/ui/file-preview/preview-runtime.js +435 -26533
  14. package/dist/ui/file-preview/shared/preview-file-types.d.ts +1 -1
  15. package/dist/ui/file-preview/src/app.d.ts +1 -5
  16. package/dist/ui/file-preview/src/app.js +384 -534
  17. package/dist/ui/file-preview/src/components/markdown-renderer.js +47 -9
  18. package/dist/ui/file-preview/src/directory-controller.d.ts +8 -0
  19. package/dist/ui/file-preview/src/directory-controller.js +233 -0
  20. package/dist/ui/file-preview/src/document-layout.d.ts +20 -0
  21. package/dist/ui/file-preview/src/document-layout.js +109 -0
  22. package/dist/ui/file-preview/src/document-outline.d.ts +17 -0
  23. package/dist/ui/file-preview/src/document-outline.js +97 -0
  24. package/dist/ui/file-preview/src/document-workspace.d.ts +19 -0
  25. package/dist/ui/file-preview/src/document-workspace.js +33 -0
  26. package/dist/ui/file-preview/src/file-type-handlers.d.ts +10 -0
  27. package/dist/ui/file-preview/src/file-type-handlers.js +98 -0
  28. package/dist/ui/file-preview/src/host/external-actions.d.ts +19 -0
  29. package/dist/ui/file-preview/src/host/external-actions.js +94 -0
  30. package/dist/ui/file-preview/src/host/selection-context.d.ts +9 -0
  31. package/dist/ui/file-preview/src/host/selection-context.js +106 -0
  32. package/dist/ui/file-preview/src/markdown/conflict-dialog.d.ts +40 -0
  33. package/dist/ui/file-preview/src/markdown/conflict-dialog.js +163 -0
  34. package/dist/ui/file-preview/src/markdown/controller.d.ts +44 -0
  35. package/dist/ui/file-preview/src/markdown/controller.js +1040 -0
  36. package/dist/ui/file-preview/src/markdown/editor.d.ts +131 -0
  37. package/dist/ui/file-preview/src/markdown/editor.js +1479 -0
  38. package/dist/ui/file-preview/src/markdown/linking.d.ts +16 -0
  39. package/dist/ui/file-preview/src/markdown/linking.js +228 -0
  40. package/dist/ui/file-preview/src/markdown/outline.d.ts +2 -0
  41. package/dist/ui/file-preview/src/markdown/outline.js +16 -0
  42. package/dist/ui/file-preview/src/markdown/parser.d.ts +30 -0
  43. package/dist/ui/file-preview/src/markdown/parser.js +38 -0
  44. package/dist/ui/file-preview/src/markdown/preview.d.ts +1 -0
  45. package/dist/ui/file-preview/src/markdown/preview.js +20 -0
  46. package/dist/ui/file-preview/src/markdown/slugify.d.ts +3 -0
  47. package/dist/ui/file-preview/src/markdown/slugify.js +31 -0
  48. package/dist/ui/file-preview/src/markdown/utils.d.ts +1 -0
  49. package/dist/ui/file-preview/src/markdown/utils.js +15 -0
  50. package/dist/ui/file-preview/src/model.d.ts +35 -0
  51. package/dist/ui/file-preview/src/panel-actions.d.ts +17 -0
  52. package/dist/ui/file-preview/src/panel-actions.js +182 -0
  53. package/dist/ui/file-preview/src/path-utils.d.ts +6 -0
  54. package/dist/ui/file-preview/src/path-utils.js +64 -0
  55. package/dist/ui/file-preview/src/payload-utils.d.ts +11 -0
  56. package/dist/ui/file-preview/src/payload-utils.js +94 -0
  57. package/dist/ui/file-preview/styles.css +1066 -233
  58. package/dist/ui/shared/widget-state.d.ts +6 -1
  59. package/dist/ui/shared/widget-state.js +102 -4
  60. package/dist/utils/capture.js +1 -1
  61. package/dist/utils/files/base.d.ts +2 -0
  62. package/dist/utils/open-browser.js +1 -1
  63. package/dist/utils/toolHistory.d.ts +13 -0
  64. package/dist/utils/toolHistory.js +65 -0
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/package.json +12 -1
  68. package/dist/data/spec-kit-prompts.json +0 -123
  69. package/dist/handlers/macos-control-handlers.d.ts +0 -16
  70. package/dist/handlers/macos-control-handlers.js +0 -81
  71. package/dist/handlers/node-handlers.d.ts +0 -6
  72. package/dist/handlers/node-handlers.js +0 -73
  73. package/dist/handlers/test-crash-handler.d.ts +0 -11
  74. package/dist/handlers/test-crash-handler.js +0 -26
  75. package/dist/http-index.d.ts +0 -45
  76. package/dist/http-index.js +0 -51
  77. package/dist/http-server-auto-tunnel.js +0 -667
  78. package/dist/http-server-named-tunnel.d.ts +0 -2
  79. package/dist/http-server-named-tunnel.js +0 -167
  80. package/dist/http-server-tunnel.d.ts +0 -2
  81. package/dist/http-server-tunnel.js +0 -111
  82. package/dist/http-server.d.ts +0 -2
  83. package/dist/http-server.js +0 -270
  84. package/dist/index-oauth.d.ts +0 -2
  85. package/dist/index-oauth.js +0 -201
  86. package/dist/lib.d.ts +0 -10
  87. package/dist/lib.js +0 -10
  88. package/dist/oauth/auth-middleware.d.ts +0 -20
  89. package/dist/oauth/auth-middleware.js +0 -62
  90. package/dist/oauth/index.d.ts +0 -3
  91. package/dist/oauth/index.js +0 -3
  92. package/dist/oauth/oauth-manager.d.ts +0 -80
  93. package/dist/oauth/oauth-manager.js +0 -179
  94. package/dist/oauth/oauth-routes.d.ts +0 -3
  95. package/dist/oauth/oauth-routes.js +0 -377
  96. package/dist/oauth/provider.d.ts +0 -22
  97. package/dist/oauth/provider.js +0 -124
  98. package/dist/oauth/server.d.ts +0 -18
  99. package/dist/oauth/server.js +0 -160
  100. package/dist/oauth/types.d.ts +0 -54
  101. package/dist/oauth/types.js +0 -2
  102. package/dist/remote-device/templates/auth-success.d.ts +0 -1
  103. package/dist/remote-device/templates/auth-success.js +0 -30
  104. package/dist/setup.log +0 -275
  105. package/dist/test-docx.d.ts +0 -1
  106. package/dist/test-setup.js +0 -14
  107. package/dist/tools/docx/builders/html-builder.d.ts +0 -17
  108. package/dist/tools/docx/builders/html-builder.js +0 -92
  109. package/dist/tools/docx/builders/image.d.ts +0 -14
  110. package/dist/tools/docx/builders/image.js +0 -84
  111. package/dist/tools/docx/builders/index.d.ts +0 -11
  112. package/dist/tools/docx/builders/index.js +0 -11
  113. package/dist/tools/docx/builders/markdown-builder.d.ts +0 -2
  114. package/dist/tools/docx/builders/markdown-builder.js +0 -260
  115. package/dist/tools/docx/builders/paragraph.d.ts +0 -12
  116. package/dist/tools/docx/builders/paragraph.js +0 -29
  117. package/dist/tools/docx/builders/table.d.ts +0 -10
  118. package/dist/tools/docx/builders/table.js +0 -138
  119. package/dist/tools/docx/builders/utils.d.ts +0 -5
  120. package/dist/tools/docx/builders/utils.js +0 -18
  121. package/dist/tools/docx/constants.d.ts +0 -32
  122. package/dist/tools/docx/constants.js +0 -61
  123. package/dist/tools/docx/converters/markdown-to-html.d.ts +0 -17
  124. package/dist/tools/docx/converters/markdown-to-html.js +0 -111
  125. package/dist/tools/docx/create.d.ts +0 -21
  126. package/dist/tools/docx/create.js +0 -386
  127. package/dist/tools/docx/dom.d.ts +0 -139
  128. package/dist/tools/docx/dom.js +0 -448
  129. package/dist/tools/docx/errors.d.ts +0 -28
  130. package/dist/tools/docx/errors.js +0 -48
  131. package/dist/tools/docx/extractors/images.d.ts +0 -14
  132. package/dist/tools/docx/extractors/images.js +0 -40
  133. package/dist/tools/docx/extractors/metadata.d.ts +0 -14
  134. package/dist/tools/docx/extractors/metadata.js +0 -64
  135. package/dist/tools/docx/extractors/sections.d.ts +0 -14
  136. package/dist/tools/docx/extractors/sections.js +0 -61
  137. package/dist/tools/docx/html.d.ts +0 -17
  138. package/dist/tools/docx/html.js +0 -111
  139. package/dist/tools/docx/index.d.ts +0 -10
  140. package/dist/tools/docx/index.js +0 -10
  141. package/dist/tools/docx/markdown.d.ts +0 -84
  142. package/dist/tools/docx/markdown.js +0 -507
  143. package/dist/tools/docx/modify.d.ts +0 -28
  144. package/dist/tools/docx/modify.js +0 -271
  145. package/dist/tools/docx/operations/handlers/index.d.ts +0 -39
  146. package/dist/tools/docx/operations/handlers/index.js +0 -152
  147. package/dist/tools/docx/operations/html-manipulator.d.ts +0 -24
  148. package/dist/tools/docx/operations/html-manipulator.js +0 -352
  149. package/dist/tools/docx/operations/index.d.ts +0 -14
  150. package/dist/tools/docx/operations/index.js +0 -61
  151. package/dist/tools/docx/operations/operation-handlers.d.ts +0 -3
  152. package/dist/tools/docx/operations/operation-handlers.js +0 -67
  153. package/dist/tools/docx/operations/preprocessor.d.ts +0 -14
  154. package/dist/tools/docx/operations/preprocessor.js +0 -44
  155. package/dist/tools/docx/operations/xml-replacer.d.ts +0 -9
  156. package/dist/tools/docx/operations/xml-replacer.js +0 -35
  157. package/dist/tools/docx/operations.d.ts +0 -13
  158. package/dist/tools/docx/operations.js +0 -13
  159. package/dist/tools/docx/ops/delete-paragraph-at-body-index.d.ts +0 -11
  160. package/dist/tools/docx/ops/delete-paragraph-at-body-index.js +0 -23
  161. package/dist/tools/docx/ops/header-replace-text-exact.d.ts +0 -13
  162. package/dist/tools/docx/ops/header-replace-text-exact.js +0 -55
  163. package/dist/tools/docx/ops/index.d.ts +0 -17
  164. package/dist/tools/docx/ops/index.js +0 -70
  165. package/dist/tools/docx/ops/insert-image-after-text.d.ts +0 -24
  166. package/dist/tools/docx/ops/insert-image-after-text.js +0 -128
  167. package/dist/tools/docx/ops/insert-paragraph-after-text.d.ts +0 -12
  168. package/dist/tools/docx/ops/insert-paragraph-after-text.js +0 -74
  169. package/dist/tools/docx/ops/insert-table-after-text.d.ts +0 -19
  170. package/dist/tools/docx/ops/insert-table-after-text.js +0 -57
  171. package/dist/tools/docx/ops/replace-hyperlink-url.d.ts +0 -12
  172. package/dist/tools/docx/ops/replace-hyperlink-url.js +0 -37
  173. package/dist/tools/docx/ops/replace-paragraph-at-body-index.d.ts +0 -9
  174. package/dist/tools/docx/ops/replace-paragraph-at-body-index.js +0 -25
  175. package/dist/tools/docx/ops/replace-paragraph-text-exact.d.ts +0 -21
  176. package/dist/tools/docx/ops/replace-paragraph-text-exact.js +0 -36
  177. package/dist/tools/docx/ops/replace-table-cell-text.d.ts +0 -25
  178. package/dist/tools/docx/ops/replace-table-cell-text.js +0 -85
  179. package/dist/tools/docx/ops/set-color-for-paragraph-exact.d.ts +0 -9
  180. package/dist/tools/docx/ops/set-color-for-paragraph-exact.js +0 -24
  181. package/dist/tools/docx/ops/set-color-for-style.d.ts +0 -13
  182. package/dist/tools/docx/ops/set-color-for-style.js +0 -31
  183. package/dist/tools/docx/ops/set-paragraph-style-at-body-index.d.ts +0 -8
  184. package/dist/tools/docx/ops/set-paragraph-style-at-body-index.js +0 -57
  185. package/dist/tools/docx/ops/table-set-cell-text.d.ts +0 -9
  186. package/dist/tools/docx/ops/table-set-cell-text.js +0 -40
  187. package/dist/tools/docx/parsers/image-extractor.d.ts +0 -18
  188. package/dist/tools/docx/parsers/image-extractor.js +0 -61
  189. package/dist/tools/docx/parsers/index.d.ts +0 -9
  190. package/dist/tools/docx/parsers/index.js +0 -9
  191. package/dist/tools/docx/parsers/paragraph-parser.d.ts +0 -2
  192. package/dist/tools/docx/parsers/paragraph-parser.js +0 -88
  193. package/dist/tools/docx/parsers/table-parser.d.ts +0 -9
  194. package/dist/tools/docx/parsers/table-parser.js +0 -72
  195. package/dist/tools/docx/parsers/xml-parser.d.ts +0 -25
  196. package/dist/tools/docx/parsers/xml-parser.js +0 -71
  197. package/dist/tools/docx/parsers/zip-reader.d.ts +0 -23
  198. package/dist/tools/docx/parsers/zip-reader.js +0 -52
  199. package/dist/tools/docx/read.d.ts +0 -27
  200. package/dist/tools/docx/read.js +0 -308
  201. package/dist/tools/docx/relationships.d.ts +0 -22
  202. package/dist/tools/docx/relationships.js +0 -76
  203. package/dist/tools/docx/structure.d.ts +0 -25
  204. package/dist/tools/docx/structure.js +0 -102
  205. package/dist/tools/docx/styled-html-parser.d.ts +0 -23
  206. package/dist/tools/docx/styled-html-parser.js +0 -1262
  207. package/dist/tools/docx/types.d.ts +0 -213
  208. package/dist/tools/docx/types.js +0 -5
  209. package/dist/tools/docx/utils/escaping.d.ts +0 -13
  210. package/dist/tools/docx/utils/escaping.js +0 -26
  211. package/dist/tools/docx/utils/images.d.ts +0 -9
  212. package/dist/tools/docx/utils/images.js +0 -26
  213. package/dist/tools/docx/utils/index.d.ts +0 -12
  214. package/dist/tools/docx/utils/index.js +0 -17
  215. package/dist/tools/docx/utils/markdown.d.ts +0 -13
  216. package/dist/tools/docx/utils/markdown.js +0 -32
  217. package/dist/tools/docx/utils/paths.d.ts +0 -15
  218. package/dist/tools/docx/utils/paths.js +0 -27
  219. package/dist/tools/docx/utils/versioning.d.ts +0 -25
  220. package/dist/tools/docx/utils/versioning.js +0 -55
  221. package/dist/tools/docx/utils.d.ts +0 -101
  222. package/dist/tools/docx/utils.js +0 -299
  223. package/dist/tools/docx/validate.d.ts +0 -33
  224. package/dist/tools/docx/validate.js +0 -49
  225. package/dist/tools/docx/validators.d.ts +0 -13
  226. package/dist/tools/docx/validators.js +0 -40
  227. package/dist/tools/docx/write.d.ts +0 -17
  228. package/dist/tools/docx/write.js +0 -88
  229. package/dist/tools/docx/xml-view-test.d.ts +0 -1
  230. package/dist/tools/docx/xml-view-test.js +0 -63
  231. package/dist/tools/docx/xml-view.d.ts +0 -56
  232. package/dist/tools/docx/xml-view.js +0 -169
  233. package/dist/tools/docx/zip.d.ts +0 -21
  234. package/dist/tools/docx/zip.js +0 -35
  235. package/dist/tools/macos-control/ax-adapter.d.ts +0 -55
  236. package/dist/tools/macos-control/ax-adapter.js +0 -438
  237. package/dist/tools/macos-control/cdp-adapter.d.ts +0 -23
  238. package/dist/tools/macos-control/cdp-adapter.js +0 -402
  239. package/dist/tools/macos-control/orchestrator.d.ts +0 -77
  240. package/dist/tools/macos-control/orchestrator.js +0 -136
  241. package/dist/tools/macos-control/role-aliases.d.ts +0 -5
  242. package/dist/tools/macos-control/role-aliases.js +0 -34
  243. package/dist/tools/macos-control/types.d.ts +0 -129
  244. package/dist/tools/macos-control/types.js +0 -1
  245. package/dist/tools/pdf-processor.d.ts +0 -1
  246. package/dist/tools/pdf-processor.js +0 -3
  247. package/dist/tools/search.d.ts +0 -32
  248. package/dist/tools/search.js +0 -202
  249. package/dist/ui/file-preview/src/components/toolbar.d.ts +0 -6
  250. package/dist/ui/file-preview/src/components/toolbar.js +0 -75
  251. package/dist/ui/shared/host-lifecycle.d.ts +0 -16
  252. package/dist/ui/shared/host-lifecycle.js +0 -35
  253. package/dist/ui/shared/rpc-client.d.ts +0 -14
  254. package/dist/ui/shared/rpc-client.js +0 -72
  255. package/dist/ui/shared/theme-adaptation.d.ts +0 -10
  256. package/dist/ui/shared/theme-adaptation.js +0 -118
  257. package/dist/ui/shared/tool-header.d.ts +0 -9
  258. package/dist/ui/shared/tool-header.js +0 -25
  259. package/dist/utils/crash-logger.d.ts +0 -18
  260. package/dist/utils/crash-logger.js +0 -44
  261. package/dist/utils/dedent.d.ts +0 -8
  262. package/dist/utils/dedent.js +0 -38
  263. /package/dist/{http-server-auto-tunnel.d.ts → ui/file-preview/src/model.js} +0 -0
@@ -1,476 +1,163 @@
1
1
  /**
2
- * Top-level controller for the File Preview app. It routes structured content into the appropriate renderer, handles host events, and coordinates user-facing state changes.
2
+ * Composition root for the File Preview app. It wires host services, file-type handlers, and specialized controllers together without owning feature logic inline.
3
3
  */
4
- import { formatJsonIfPossible, inferLanguageFromPath, renderCodeViewer } from './components/code-viewer.js';
5
- import { renderHtmlPreview } from './components/html-renderer.js';
6
- import { renderMarkdown } from './components/markdown-renderer.js';
7
- import { escapeHtml } from './components/highlighting.js';
8
- import { isAllowedImageMimeType, normalizeImageMimeType } from './image-preview.js';
4
+ import { App } from '@modelcontextprotocol/ext-apps';
9
5
  import { createCompactRowShellController } from '../../shared/tool-shell.js';
10
6
  import { createWidgetStateStorage } from '../../shared/widget-state.js';
11
7
  import { renderCompactRow } from '../../shared/compact-row.js';
12
- import { connectWithSharedHostContext, isObjectRecord } from '../../shared/host-context.js';
8
+ import { connectWithSharedHostContext } from '../../shared/host-context.js';
13
9
  import { createUiEventTracker } from '../../shared/ui-event-tracker.js';
14
- import { App } from '@modelcontextprotocol/ext-apps';
10
+ import { attachDirectoryHandlers } from './directory-controller.js';
11
+ import { buildDocumentLayout } from './document-layout.js';
12
+ import { getDocumentFullscreenAvailability, parseReadRange, stripReadStatusLine } from './document-workspace.js';
13
+ import { getFileTypeCapabilities, renderPayloadBody } from './file-type-handlers.js';
14
+ import { buildOpenInEditorCommand, buildOpenInFolderCommand, detectDefaultMarkdownEditor, renderMarkdownEditorAppIcon } from './host/external-actions.js';
15
+ import { attachSelectionContext } from './host/selection-context.js';
16
+ import { createMarkdownController } from './markdown/controller.js';
17
+ import { createConflictDialogController, renderConflictDialogMarkup, } from './markdown/conflict-dialog.js';
18
+ import { attachPanelActions } from './panel-actions.js';
19
+ import { extractRenderPayload, extractToolText, getFileExtensionForAnalytics, isLikelyUrl, isPreviewStructuredContent } from './payload-utils.js';
15
20
  let isExpanded = false;
16
21
  let hideSummaryRow = false;
17
22
  let previewShownFired = false;
18
23
  let onRender;
19
24
  let trackUiEvent;
25
+ let conflictDialogController;
20
26
  let rpcCallTool;
21
27
  let rpcUpdateContext;
28
+ let openExternalLink;
29
+ let requestDisplayMode;
22
30
  let shellController;
23
- function getFileExtensionForAnalytics(filePath) {
24
- const normalizedPath = filePath.trim().replace(/\\/g, '/');
25
- const fileName = normalizedPath.split('/').pop() ?? normalizedPath;
26
- const dotIndex = fileName.lastIndexOf('.');
27
- if (dotIndex <= 0 || dotIndex === fileName.length - 1) {
28
- return 'none';
29
- }
30
- return fileName.slice(dotIndex + 1).toLowerCase();
31
- }
32
- function isPreviewStructuredContent(value) {
33
- if (!isObjectRecord(value)) {
34
- return false;
35
- }
36
- return (typeof value.fileName === 'string' &&
37
- typeof value.filePath === 'string' &&
38
- typeof value.fileType === 'string');
39
- }
40
- function buildRenderPayload(meta, text) {
41
- return { ...meta, content: text };
42
- }
43
- function extractRenderPayload(value) {
44
- if (!isObjectRecord(value)) {
45
- return undefined;
46
- }
47
- const meta = isPreviewStructuredContent(value.structuredContent)
48
- ? value.structuredContent
49
- : isPreviewStructuredContent(value)
50
- ? value
51
- : null;
52
- if (!meta)
53
- return undefined;
54
- const text = extractToolText(value) ?? extractToolText(value.structuredContent) ?? '';
55
- return buildRenderPayload(meta, text);
56
- }
57
- function extractToolText(value) {
58
- if (!isObjectRecord(value)) {
59
- return undefined;
60
- }
61
- const content = value.content;
62
- if (!Array.isArray(content)) {
63
- return undefined;
64
- }
65
- for (const item of content) {
66
- if (!isObjectRecord(item)) {
67
- continue;
68
- }
69
- if (item.type === 'text' && typeof item.text === 'string' && item.text.trim().length > 0) {
70
- return item.text;
71
- }
72
- }
73
- return undefined;
74
- }
75
- function isLikelyUrl(filePath) {
76
- return /^https?:\/\//i.test(filePath);
77
- }
78
- function buildBreadcrumb(filePath) {
79
- const normalized = filePath.replace(/\\/g, '/');
80
- const parts = normalized.split('/').filter(Boolean);
81
- // Show last 3-4 meaningful segments as breadcrumb
82
- const tail = parts.slice(-4);
83
- return tail.map(p => escapeHtml(p)).join(' <span class="breadcrumb-sep">›</span> ');
84
- }
85
- function getParentDirectory(filePath) {
86
- const normalized = filePath.replace(/\\/g, '/');
87
- const lastSlash = normalized.lastIndexOf('/');
88
- if (lastSlash <= 0) {
89
- return filePath;
90
- }
91
- return normalized.slice(0, lastSlash);
92
- }
93
- function shellQuote(value) {
94
- return `'${value.replace(/'/g, `'\\''`)}'`;
95
- }
96
- function encodePowerShellCommand(script) {
97
- // PowerShell -EncodedCommand expects UTF-16LE bytes.
98
- const utf16leBytes = [];
99
- for (let index = 0; index < script.length; index += 1) {
100
- const codeUnit = script.charCodeAt(index);
101
- utf16leBytes.push(codeUnit & 0xff, codeUnit >> 8);
102
- }
103
- let binary = '';
104
- for (const byte of utf16leBytes) {
105
- binary += String.fromCharCode(byte);
106
- }
107
- return btoa(binary);
31
+ let currentPayload;
32
+ let currentHtmlMode = 'rendered';
33
+ let currentHostContext;
34
+ let rerenderCurrent;
35
+ let syncPayload;
36
+ let persistPayload;
37
+ let localPayloadOverride;
38
+ let hostPayload;
39
+ let inlinePayloadBeforeFullscreen;
40
+ let directoryBackPayload;
41
+ let selectionAbortController = null;
42
+ const markdownEditorAppCache = new Map();
43
+ const markdownEditorAppPending = new Set();
44
+ async function callToolIfReady(name, args) {
45
+ return rpcCallTool ? rpcCallTool(name, args) : undefined;
108
46
  }
109
- function buildOpenInFolderCommand(filePath) {
110
- const trimmedPath = filePath.trim();
111
- if (!trimmedPath || isLikelyUrl(trimmedPath)) {
112
- return undefined;
47
+ function getAvailableDisplayModes() {
48
+ const rawModes = currentHostContext?.availableDisplayModes;
49
+ if (!Array.isArray(rawModes)) {
50
+ return [];
113
51
  }
114
- const userAgent = navigator.userAgent.toLowerCase();
115
- if (userAgent.includes('win')) {
116
- const escapedForPowerShell = trimmedPath.replace(/'/g, "''");
117
- const script = `Start-Process -FilePath explorer.exe -ArgumentList @('/select,','${escapedForPowerShell}')`;
118
- return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`;
119
- }
120
- if (userAgent.includes('mac')) {
121
- return `open -R ${shellQuote(trimmedPath)}`;
122
- }
123
- return `xdg-open ${shellQuote(getParentDirectory(trimmedPath))}`;
52
+ return rawModes.filter((mode) => typeof mode === 'string');
124
53
  }
125
- function renderRawFallback(source) {
126
- return `<pre class="code-viewer"><code class="hljs language-text">${escapeHtml(source)}</code></pre>`;
54
+ function getCurrentDisplayMode() {
55
+ return typeof currentHostContext?.displayMode === 'string'
56
+ ? currentHostContext.displayMode
57
+ : null;
127
58
  }
128
- function stripReadStatusLine(content) {
129
- // Remove the synthetic read status header shown by read_file pagination.
130
- return content.replace(/^\[Reading [^\]]+\]\r?\n?/, '');
59
+ function storePayloadOverride(payload) {
60
+ localPayloadOverride = payload;
61
+ currentPayload = payload;
62
+ persistPayload?.(payload);
131
63
  }
132
- function renderImageBody(payload) {
133
- const mimeType = normalizeImageMimeType(payload.mimeType);
134
- if (!isAllowedImageMimeType(mimeType)) {
135
- return {
136
- notice: 'Preview is unavailable for this image format.',
137
- html: '<div class="panel-content source-content"></div>'
138
- };
139
- }
140
- if (!payload.imageData || payload.imageData.trim().length === 0) {
141
- return {
142
- notice: 'Preview is unavailable because image data is missing.',
143
- html: '<div class="panel-content source-content"></div>'
144
- };
64
+ function getEffectiveIncomingPayload(payload) {
65
+ if (!localPayloadOverride) {
66
+ return payload;
145
67
  }
146
- const src = `data:${mimeType};base64,${payload.imageData}`;
147
- return {
148
- html: `<div class="panel-content image-content"><div class="image-preview"><img src="${escapeHtml(src)}" alt="${escapeHtml(payload.fileName)}" loading="eager" decoding="async"></div></div>`
149
- };
150
- }
151
- function countContentLines(content) {
152
- const cleaned = stripReadStatusLine(content);
153
- if (cleaned === '')
154
- return 0;
155
- const lines = cleaned.split('\n');
156
- return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
157
- }
158
- function parseReadRange(content) {
159
- // Parse "[Reading N lines from line M (total: T lines, R remaining)]"
160
- // or "[Reading N lines from start (total: T lines, R remaining)]"
161
- const match = content.match(/^\[Reading (\d+) lines from (?:line )?(\d+|start) \(total: (\d+) lines/);
162
- if (!match)
163
- return undefined;
164
- const count = parseInt(match[1], 10);
165
- const from = match[2] === 'start' ? 1 : parseInt(match[2], 10);
166
- const total = parseInt(match[3], 10);
167
- return {
168
- fromLine: from,
169
- toLine: from + count - 1,
170
- totalLines: total,
171
- isPartial: count < total
172
- };
173
- }
174
- function renderBody(payload, htmlMode, startLine = 1) {
175
- const cleanedContent = stripReadStatusLine(payload.content);
176
- if (payload.fileType === 'image') {
177
- return renderImageBody(payload);
68
+ if (localPayloadOverride.filePath !== payload.filePath) {
69
+ localPayloadOverride = undefined;
70
+ return payload;
178
71
  }
179
- if (payload.fileType === 'unsupported') {
180
- return {
181
- notice: 'Preview is not available for this file type.',
182
- html: '<div class="panel-content source-content"></div>'
183
- };
184
- }
185
- if (payload.fileType === 'html') {
186
- return renderHtmlPreview(cleanedContent, htmlMode);
187
- }
188
- if (payload.fileType !== 'markdown') {
189
- const detectedLanguage = inferLanguageFromPath(payload.filePath);
190
- const formatted = formatJsonIfPossible(cleanedContent, payload.filePath);
191
- return {
192
- notice: formatted.notice,
193
- html: `<div class="panel-content source-content">${renderCodeViewer(formatted.content, detectedLanguage, startLine)}</div>`
194
- };
195
- }
196
- try {
197
- return {
198
- html: `<div class="panel-content markdown-content"><article class="markdown markdown-doc">${renderMarkdown(cleanedContent)}</article></div>`
199
- };
200
- }
201
- catch {
202
- return {
203
- notice: 'Markdown renderer failed. Showing raw source instead.',
204
- html: `<div class="panel-content source-content">${renderRawFallback(cleanedContent)}</div>`
205
- };
72
+ const incomingContent = stripReadStatusLine(payload.content);
73
+ const overriddenContent = stripReadStatusLine(localPayloadOverride.content);
74
+ if (incomingContent === overriddenContent) {
75
+ return payload;
206
76
  }
77
+ return localPayloadOverride;
207
78
  }
208
- function attachCopyHandler(payload) {
209
- const copyButton = document.getElementById('copy-source');
210
- if (!copyButton) {
211
- return;
212
- }
213
- const fallbackCopy = (text) => {
214
- const textArea = document.createElement('textarea');
215
- textArea.value = text;
216
- textArea.setAttribute('readonly', '');
217
- textArea.style.position = 'fixed';
218
- textArea.style.top = '-9999px';
219
- document.body.appendChild(textArea);
220
- textArea.select();
221
- const success = document.execCommand('copy');
222
- document.body.removeChild(textArea);
223
- return success;
224
- };
225
- const setButtonState = (label, revertMs) => {
226
- copyButton.setAttribute('title', label);
227
- copyButton.setAttribute('aria-label', label);
228
- copyButton.textContent = label;
229
- if (revertMs) {
230
- setTimeout(() => {
231
- copyButton.textContent = 'Copy';
232
- copyButton.setAttribute('title', 'Copy source');
233
- copyButton.setAttribute('aria-label', 'Copy source');
234
- }, revertMs);
79
+ function updateSaveStatusDOM(label, statusClass) {
80
+ const existing = document.querySelector('.panel-save-status');
81
+ if (label) {
82
+ if (existing) {
83
+ existing.textContent = label;
84
+ existing.className = `panel-save-status panel-save-status--${statusClass}`;
235
85
  }
236
- };
237
- const copyTextData = async (text) => {
238
- try {
239
- if (navigator.clipboard?.writeText) {
240
- await navigator.clipboard.writeText(text);
241
- return true;
86
+ else {
87
+ const actions = document.querySelector('.panel-topbar-actions');
88
+ if (actions) {
89
+ const span = document.createElement('span');
90
+ span.className = `panel-save-status panel-save-status--${statusClass}`;
91
+ span.textContent = label;
92
+ actions.prepend(span);
242
93
  }
243
- return fallbackCopy(text);
244
94
  }
245
- catch {
246
- return fallbackCopy(text);
247
- }
248
- };
249
- copyButton.addEventListener('click', async () => {
250
- trackUiEvent?.('copy_clicked', {
251
- file_type: payload.fileType,
252
- file_extension: getFileExtensionForAnalytics(payload.filePath)
253
- });
254
- const cleanedContent = stripReadStatusLine(payload.content);
255
- const copied = await copyTextData(cleanedContent);
256
- setButtonState(copied ? 'Copied!' : 'Copy failed', 1500);
257
- });
258
- }
259
- function attachHtmlToggleHandler(container, payload, htmlMode) {
260
- const toggleButton = document.getElementById('toggle-html-mode');
261
- if (!toggleButton || payload.fileType !== 'html') {
262
- return;
263
95
  }
264
- toggleButton.addEventListener('click', () => {
265
- const nextMode = htmlMode === 'rendered' ? 'source' : 'rendered';
266
- trackUiEvent?.('html_view_toggled', {
267
- file_type: payload.fileType,
268
- file_extension: getFileExtensionForAnalytics(payload.filePath)
269
- });
270
- renderApp(container, payload, nextMode, isExpanded);
271
- });
272
- }
273
- function attachOpenInFolderHandler(payload) {
274
- const openButton = document.getElementById('open-in-folder');
275
- if (!openButton) {
276
- return;
277
- }
278
- const command = buildOpenInFolderCommand(payload.filePath);
279
- if (!command) {
280
- openButton.disabled = true;
281
- return;
96
+ else if (existing) {
97
+ existing.remove();
282
98
  }
283
- openButton.addEventListener('click', async () => {
284
- trackUiEvent?.('open_in_folder', {
285
- file_type: payload.fileType,
286
- file_extension: getFileExtensionForAnalytics(payload.filePath)
287
- });
288
- try {
289
- await rpcCallTool?.('start_process', {
290
- command,
291
- timeout_ms: 12000
292
- });
293
- }
294
- catch {
295
- // Keep UI stable if opening folder fails.
296
- }
297
- });
298
99
  }
299
- function attachLoadAllHandler(container, payload, htmlMode) {
300
- const beforeBtn = document.getElementById('load-before');
301
- const afterBtn = document.getElementById('load-after');
302
- if (!beforeBtn && !afterBtn) {
303
- return;
304
- }
305
- const range = parseReadRange(payload.content);
306
- if (!range?.isPartial)
307
- return;
308
- const currentContent = stripReadStatusLine(payload.content);
309
- const loadLines = async (btn, direction) => {
310
- const originalText = btn.textContent;
311
- btn.textContent = 'Loading…';
312
- btn.disabled = true;
313
- trackUiEvent?.(direction === 'before' ? 'load_lines_before' : 'load_lines_after', {
314
- file_type: payload.fileType,
315
- file_extension: getFileExtensionForAnalytics(payload.filePath)
316
- });
317
- try {
318
- // Load only the missing portion
319
- const readArgs = direction === 'before'
320
- ? { path: payload.filePath, offset: 0, length: range.fromLine - 1 }
321
- : { path: payload.filePath, offset: range.toLine };
322
- const result = await rpcCallTool?.('read_file', readArgs);
323
- const resultObj = result;
324
- const newText = resultObj?.content?.[0]?.text;
325
- if (newText && typeof newText === 'string') {
326
- const cleanNew = stripReadStatusLine(newText);
327
- // Merge: prepend or append the new lines
328
- const merged = direction === 'before'
329
- ? cleanNew + (cleanNew.endsWith('\n') ? '' : '\n') + currentContent
330
- : currentContent + (currentContent.endsWith('\n') ? '' : '\n') + cleanNew;
331
- // Build updated status line reflecting the new range
332
- const newFrom = direction === 'before' ? 1 : range.fromLine;
333
- const newTo = direction === 'after' ? range.totalLines : range.toLine;
334
- const lineCount = newTo - newFrom + 1;
335
- const remaining = range.totalLines - newTo;
336
- const isStillPartial = newFrom > 1 || newTo < range.totalLines;
337
- const statusLine = isStillPartial
338
- ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n`
339
- : '';
340
- const mergedPayload = {
341
- ...payload,
342
- content: statusLine + merged
343
- };
344
- renderApp(container, mergedPayload, htmlMode, isExpanded);
345
- }
346
- else {
347
- btn.textContent = 'Failed to load';
348
- setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000);
349
- }
350
- }
351
- catch {
352
- btn.textContent = 'Failed to load';
353
- setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000);
100
+ const markdownController = createMarkdownController({
101
+ callTool: callToolIfReady,
102
+ openExternalLink: async (url) => (openExternalLink ? openExternalLink(url) : undefined),
103
+ requestDisplayMode: async (mode) => (requestDisplayMode ? requestDisplayMode(mode) : undefined),
104
+ getAvailableDisplayModes,
105
+ getCurrentDisplayMode,
106
+ getCurrentPayload: () => currentPayload,
107
+ setExpanded: (expanded) => {
108
+ isExpanded = expanded;
109
+ },
110
+ syncPayload: (payload) => syncPayload?.(payload),
111
+ storePayloadOverride,
112
+ rerender: () => {
113
+ rerenderCurrent?.();
114
+ },
115
+ updateSaveStatus: updateSaveStatusDOM,
116
+ trackUiEvent: (event, params) => trackUiEvent?.(event, params),
117
+ showConflictDialog: (options) => {
118
+ if (conflictDialogController) {
119
+ conflictDialogController.open(options);
120
+ return;
354
121
  }
355
- };
356
- beforeBtn?.addEventListener('click', () => void loadLines(beforeBtn, 'before'));
357
- afterBtn?.addEventListener('click', () => void loadLines(afterBtn, 'after'));
358
- }
122
+ // Dialog not yet initialized (would only happen if the save failure
123
+ // somehow fires before bootstrapApp). Fall back to the cancel callback
124
+ // so the editor still shows its inline note instead of silently no-op'ing.
125
+ console.warn('[file-preview] conflictDialogController not ready; firing onCancel fallback');
126
+ options.onCancel?.();
127
+ },
128
+ });
359
129
  /**
360
- * Tracks native text selection and pushes it to the host via ui/update-model-context.
361
- *
362
- * How it works:
363
- * 1. User drags to select text anywhere in the preview (markdown, code, HTML).
364
- * 2. The selectionchange event fires; we extract the selected string.
365
- * 3. We call rpcUpdateContext() which sends a ui/update-model-context JSON-RPC
366
- * request to the host with the selected text + file path (+ line numbers for code).
367
- * 4. The host stores this as widget context.
368
- * 5. The LLM can access it by calling read_widget_context(tool_name="desktop-commander:read_file").
369
- *
370
- * Note: as of Feb 2025, Claude does NOT auto-inject ui/update-model-context into
371
- * the LLM's context window. The LLM must actively call read_widget_context to see
372
- * the selection. A floating tooltip near the selection tells the user this is working.
130
+ * Check if a payload needs its file content to be read.
131
+ * Tool results from edit_block/write_file include structuredContent but
132
+ * their text is a success message, not file content. Detect this by
133
+ * checking for the absence of the read status line that read_file always includes.
134
+ * URL payloads are fetched remotely by read_file(isUrl:true); we can't
135
+ * re-fetch them from here (no isUrl flag on the refresh path), so skip.
373
136
  */
374
- let selectionAbortController = null;
375
- function attachTextSelectionHandler(payload) {
376
- const contentWrapper = document.querySelector('.panel-content-wrapper');
377
- if (!contentWrapper)
378
- return;
379
- // Abort any previous selectionchange listener to avoid leaking listeners/closures
380
- if (selectionAbortController) {
381
- selectionAbortController.abort();
382
- selectionAbortController = null;
383
- }
384
- selectionAbortController = new AbortController();
385
- let hintEl = null;
386
- let lastSelectedText = '';
387
- let hideTimer = null;
388
- function positionHint(selection) {
389
- if (!hintEl)
390
- return;
391
- const range = selection.getRangeAt(0);
392
- const rect = range.getBoundingClientRect();
393
- const wrapperRect = contentWrapper.getBoundingClientRect();
394
- // Position above the selection, centered horizontally
395
- let left = rect.left + rect.width / 2 - wrapperRect.left;
396
- let top = rect.top - wrapperRect.top + contentWrapper.scrollTop - 32;
397
- // Clamp within wrapper bounds
398
- const hintWidth = hintEl.offsetWidth || 200;
399
- left = Math.max(8, Math.min(left - hintWidth / 2, contentWrapper.clientWidth - hintWidth - 8));
400
- top = Math.max(4, top);
401
- hintEl.style.left = `${left}px`;
402
- hintEl.style.top = `${top}px`;
403
- }
404
- function showHint(selection) {
405
- if (hideTimer) {
406
- clearTimeout(hideTimer);
407
- hideTimer = null;
408
- }
409
- if (!hintEl) {
410
- hintEl = document.createElement('div');
411
- hintEl.className = 'selection-hint';
412
- hintEl.textContent = 'AI can see your selection';
413
- contentWrapper.appendChild(hintEl);
414
- }
415
- hintEl.classList.add('visible');
416
- positionHint(selection);
417
- }
418
- function hideHint() {
419
- if (!hintEl)
420
- return;
421
- hintEl.classList.remove('visible');
422
- hideTimer = setTimeout(() => { hintEl?.remove(); hintEl = null; }, 200);
137
+ function needsContentRead(payload) {
138
+ if (payload.fileType === 'directory' || payload.fileType === 'image' || payload.fileType === 'unsupported') {
139
+ return false;
423
140
  }
424
- function getLineInfo(selection) {
425
- const anchorRow = selection.anchorNode?.parentElement?.closest('.code-line');
426
- const focusRow = selection.focusNode?.parentElement?.closest('.code-line');
427
- if (anchorRow && focusRow) {
428
- const a = parseInt(anchorRow.dataset.line ?? '', 10);
429
- const f = parseInt(focusRow.dataset.line ?? '', 10);
430
- if (!isNaN(a) && !isNaN(f)) {
431
- const low = Math.min(a, f);
432
- const high = Math.max(a, f);
433
- return low === high ? `line ${low}` : `lines ${low}–${high}`;
434
- }
435
- }
436
- return '';
141
+ if (/^https?:\/\//i.test(payload.filePath)) {
142
+ return false;
437
143
  }
438
- document.addEventListener('selectionchange', () => {
439
- const selection = document.getSelection();
440
- if (!selection || selection.isCollapsed) {
441
- if (lastSelectedText) {
442
- lastSelectedText = '';
443
- rpcUpdateContext?.('');
444
- hideHint();
445
- }
446
- return;
447
- }
448
- const text = selection.toString().trim();
449
- if (!text || text === lastSelectedText)
450
- return;
451
- // Only act on selections within our content area
452
- const anchorInContent = contentWrapper.contains(selection.anchorNode);
453
- const focusInContent = contentWrapper.contains(selection.focusNode);
454
- if (!anchorInContent && !focusInContent) {
455
- if (lastSelectedText) {
456
- lastSelectedText = '';
457
- rpcUpdateContext?.('');
458
- hideHint();
144
+ return !parseReadRange(payload.content);
145
+ }
146
+ async function readAndResolvePayload(payload, onReady) {
147
+ try {
148
+ const freshPayload = await markdownController.readPayload(payload.filePath);
149
+ if (freshPayload) {
150
+ onReady(freshPayload);
151
+ if (freshPayload.fileType === 'markdown') {
152
+ void markdownController.refreshFromDisk(freshPayload);
459
153
  }
460
154
  return;
461
155
  }
462
- lastSelectedText = text;
463
- const lineInfo = getLineInfo(selection);
464
- const locationPart = lineInfo ? ` (${lineInfo})` : '';
465
- const context = `User selected text from file ${payload.filePath}${locationPart}:\n\`\`\`\n${text}\n\`\`\``;
466
- rpcUpdateContext?.(context);
467
- showHint(selection);
468
- trackUiEvent?.('text_selected', {
469
- file_type: payload.fileType,
470
- file_extension: getFileExtensionForAnalytics(payload.filePath),
471
- char_count: text.length
472
- });
473
- }, { signal: selectionAbortController.signal });
156
+ }
157
+ catch {
158
+ // Fall through to original payload.
159
+ }
160
+ onReady(payload);
474
161
  }
475
162
  function renderStatusState(container, message) {
476
163
  container.innerHTML = `
@@ -490,108 +177,149 @@ function renderLoadingState(container) {
490
177
  }
491
178
  export function renderApp(container, payload, htmlMode = 'rendered', expandedState = false) {
492
179
  isExpanded = expandedState;
180
+ currentHtmlMode = htmlMode;
493
181
  shellController?.dispose();
494
182
  shellController = undefined;
183
+ if (!payload || payload.fileType !== 'markdown') {
184
+ markdownController.clear();
185
+ }
186
+ else {
187
+ markdownController.disposeHandles();
188
+ }
495
189
  if (!payload) {
190
+ selectionAbortController?.abort();
191
+ selectionAbortController = null;
192
+ currentPayload = undefined;
496
193
  renderStatusState(container, 'No preview available for this response.');
497
194
  onRender?.();
498
195
  return;
499
196
  }
500
- const canCopy = payload.fileType !== 'unsupported' && payload.fileType !== 'image';
501
- const canOpenInFolder = !isLikelyUrl(payload.filePath);
502
- const fileExtension = getFileExtensionForAnalytics(payload.filePath);
503
- const supportsPreview = payload.fileType !== 'unsupported';
504
- // In DC app (hideSummaryRow), no reason to auto-expand when there's nothing to preview —
505
- // the host header already shows the file name and path.
506
- if (!supportsPreview && hideSummaryRow) {
197
+ currentPayload = payload;
198
+ const capabilities = getFileTypeCapabilities(payload);
199
+ if (!capabilities.supportsPreview && hideSummaryRow) {
507
200
  isExpanded = false;
508
201
  }
509
202
  const range = parseReadRange(payload.content);
510
- const body = renderBody(payload, htmlMode, range?.fromLine ?? 1);
511
- const notice = body.notice ? `<div class="notice">${body.notice}</div>` : '';
512
- const breadcrumb = buildBreadcrumb(payload.filePath);
513
- const lineCount = range ? range.toLine - range.fromLine + 1 : countContentLines(payload.content);
514
- const fileTypeLabel = payload.fileType === 'markdown' ? 'MARKDOWN'
515
- : payload.fileType === 'html' ? 'HTML'
516
- : payload.fileType === 'image' ? 'IMAGE'
517
- : fileExtension !== 'none' ? fileExtension.toUpperCase()
518
- : 'TEXT';
519
- const compactLabel = range?.isPartial
520
- ? `View lines ${range.fromLine}–${range.toLine}`
521
- : 'View file';
522
- const footerLabel = range?.isPartial
523
- ? `${escapeHtml(fileTypeLabel)} • LINES ${range.fromLine}–${range.toLine} OF ${range.totalLines}`
524
- : `${escapeHtml(fileTypeLabel)} • ${lineCount} LINE${lineCount !== 1 ? 'S' : ''}`;
525
- const htmlToggle = payload.fileType === 'html'
526
- ? `<button class="panel-action" id="toggle-html-mode">${htmlMode === 'rendered' ? 'Source' : 'Rendered'}</button>`
527
- : '';
528
- const copyIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
529
- const folderIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
530
- // Content-area banners for missing lines
531
- const hasMissingBefore = range?.isPartial && range.fromLine > 1;
532
- const hasMissingAfter = range?.isPartial && range.toLine < range.totalLines && (range.totalLines - range.toLine) > 1;
533
- const loadBeforeBanner = hasMissingBefore
534
- ? `<button class="load-lines-banner" id="load-before">↑ Load lines 1–${range.fromLine - 1}</button>`
535
- : '';
536
- const loadAfterBanner = hasMissingAfter
537
- ? `<button class="load-lines-banner" id="load-after">↓ Load lines ${range.toLine + 1}–${range.totalLines}</button>`
538
- : '';
539
- container.innerHTML = `
540
- <main id="tool-shell" class="shell tool-shell ${isExpanded ? 'expanded' : 'collapsed'}${hideSummaryRow ? ' host-framed' : ''}">
541
- ${renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })}
542
- <section class="panel">
543
- <div class="panel-topbar">
544
- <span class="panel-breadcrumb" title="${escapeHtml(payload.filePath)}">${breadcrumb}</span>
545
- <span class="panel-topbar-actions">
546
- ${htmlToggle}
547
- ${canOpenInFolder ? `<button class="panel-action" id="open-in-folder">${folderIcon} Open in folder</button>` : ''}
548
- ${canCopy && supportsPreview ? `<button class="panel-action" id="copy-source" title="Copy source" aria-label="Copy source">${copyIcon} Copy</button>` : ''}
549
- </span>
550
- </div>
551
- ${notice}
552
- <div class="panel-content-wrapper">
553
- ${loadBeforeBanner}
554
- ${body.html}
555
- ${loadAfterBanner}
556
- </div>
557
- <div class="panel-footer">
558
- <span>${footerLabel}</span>
559
- </div>
560
- </section>
561
- </main>
562
- `;
203
+ const body = renderPayloadBody({
204
+ payload,
205
+ htmlMode,
206
+ startLine: range?.fromLine ?? 1,
207
+ markdownController,
208
+ });
209
+ const markdownWorkspace = payload.fileType === 'markdown' ? markdownController.getState(payload) : undefined;
210
+ const fileExtension = getFileExtensionForAnalytics(payload.filePath);
211
+ const isFullscreen = getCurrentDisplayMode() === 'fullscreen';
212
+ const canGoFullscreen = !isFullscreen && getDocumentFullscreenAvailability({
213
+ availableDisplayModes: getAvailableDisplayModes(),
214
+ }).canFullscreen;
215
+ const defaultMarkdownEditor = payload.fileType === 'markdown'
216
+ ? markdownEditorAppCache.get(payload.filePath)
217
+ : undefined;
218
+ if (payload.fileType === 'markdown' && !defaultMarkdownEditor) {
219
+ void detectDefaultMarkdownEditor({
220
+ filePath: payload.filePath,
221
+ editorAppCache: markdownEditorAppCache,
222
+ editorAppPending: markdownEditorAppPending,
223
+ callTool: callToolIfReady,
224
+ extractToolText,
225
+ onDetected: () => {
226
+ rerenderCurrent?.();
227
+ },
228
+ });
229
+ }
230
+ const layout = buildDocumentLayout({
231
+ payload,
232
+ body,
233
+ capabilities,
234
+ fileExtension,
235
+ htmlMode,
236
+ currentDisplayMode: getCurrentDisplayMode(),
237
+ isExpanded,
238
+ hideSummaryRow,
239
+ markdownWorkspace,
240
+ canGoFullscreen,
241
+ isMarkdownUndoAvailable: markdownWorkspace ? markdownController.isUndoAvailable(markdownWorkspace) : false,
242
+ defaultMarkdownEditorName: defaultMarkdownEditor?.appName,
243
+ markdownEditorAppIcon: renderMarkdownEditorAppIcon(),
244
+ hasDirectoryBackButton: Boolean(directoryBackPayload),
245
+ });
246
+ container.innerHTML = layout.html;
563
247
  document.body.classList.add('dc-ready');
564
- attachCopyHandler(payload);
565
- attachHtmlToggleHandler(container, payload, htmlMode);
566
- attachOpenInFolderHandler(payload);
567
- attachLoadAllHandler(container, payload, htmlMode);
568
- attachTextSelectionHandler(payload);
248
+ attachPanelActions({
249
+ container,
250
+ payload,
251
+ htmlMode,
252
+ getIsExpanded: () => isExpanded,
253
+ callTool: callToolIfReady,
254
+ trackUiEvent,
255
+ getFileExtensionForAnalytics,
256
+ buildOpenInFolderCommand: (filePath) => buildOpenInFolderCommand(filePath, isLikelyUrl),
257
+ buildOpenInEditorCommand: (filePath) => buildOpenInEditorCommand(filePath, isLikelyUrl, markdownEditorAppCache),
258
+ render: (nextPayload, nextHtmlMode = 'rendered', nextExpanded = isExpanded) => {
259
+ renderApp(container, nextPayload, nextHtmlMode, nextExpanded);
260
+ },
261
+ updateSaveStatus: updateSaveStatusDOM,
262
+ markdownController,
263
+ });
264
+ if (payload.fileType === 'markdown') {
265
+ markdownController.attachHandlers(payload);
266
+ }
267
+ selectionAbortController = attachSelectionContext({
268
+ payload,
269
+ isMarkdownEditing: payload.fileType === 'markdown' && !!markdownWorkspace,
270
+ updateContext: rpcUpdateContext,
271
+ trackUiEvent,
272
+ getFileExtensionForAnalytics,
273
+ previousAbortController: selectionAbortController,
274
+ });
275
+ if (payload.fileType === 'directory') {
276
+ attachDirectoryHandlers({
277
+ container,
278
+ callTool: callToolIfReady,
279
+ buildOpenInFolderCommand: (filePath) => buildOpenInFolderCommand(filePath, isLikelyUrl),
280
+ onOpenPayload: (nextPayload) => {
281
+ directoryBackPayload = payload;
282
+ renderApp(container, nextPayload, 'rendered', true);
283
+ },
284
+ });
285
+ }
286
+ const backBtn = document.getElementById('dir-back');
287
+ if (backBtn && directoryBackPayload) {
288
+ const savedPayload = directoryBackPayload;
289
+ backBtn.addEventListener('click', () => {
290
+ directoryBackPayload = undefined;
291
+ renderApp(container, savedPayload, 'rendered', true);
292
+ });
293
+ }
294
+ if (payload.fileType === 'directory') {
295
+ directoryBackPayload = undefined;
296
+ }
569
297
  const compactRow = document.getElementById('compact-toggle');
570
298
  shellController = createCompactRowShellController({
571
299
  shell: document.getElementById('tool-shell'),
572
300
  compactRow,
573
- initialExpanded: isExpanded,
301
+ initialExpanded: layout.effectiveExpanded,
574
302
  onToggle: (expanded) => {
575
303
  isExpanded = expanded;
576
304
  trackUiEvent?.(expanded ? 'expand' : 'collapse', {
577
305
  file_type: payload.fileType,
578
- file_extension: fileExtension
306
+ file_extension: fileExtension,
579
307
  });
580
308
  },
581
309
  onScrollAfterExpand: () => {
582
310
  trackUiEvent?.('scroll_after_expand', {
583
311
  file_type: payload.fileType,
584
- file_extension: fileExtension
312
+ file_extension: fileExtension,
585
313
  });
586
314
  },
587
- onRender
315
+ onRender,
588
316
  });
589
317
  onRender?.();
590
318
  if (!previewShownFired) {
591
319
  previewShownFired = true;
592
320
  trackUiEvent?.('preview_shown', {
593
321
  file_type: payload.fileType,
594
- file_extension: fileExtension
322
+ file_extension: fileExtension,
595
323
  });
596
324
  }
597
325
  }
@@ -601,8 +329,18 @@ export function bootstrapApp() {
601
329
  return;
602
330
  }
603
331
  renderLoadingState(container);
604
- // Use the official App class it connects to the host via PostMessageTransport
605
- // (window.parent by default) and speaks standard MCP JSON-RPC 2.0 over postMessage.
332
+ // Mount the conflict dialog once at body level. It's position: fixed and
333
+ // must live outside the app container so that re-renders of the document
334
+ // body never wipe it while it's open.
335
+ if (!document.getElementById('md-conflict-modal')) {
336
+ const dialogHost = document.createElement('div');
337
+ dialogHost.innerHTML = renderConflictDialogMarkup();
338
+ const dialogRoot = dialogHost.firstElementChild;
339
+ if (dialogRoot) {
340
+ document.body.appendChild(dialogRoot);
341
+ }
342
+ }
343
+ conflictDialogController = createConflictDialogController({ container: document });
606
344
  const app = new App({ name: 'Desktop Commander File Preview', version: '1.0.0' }, { updateModelContext: { text: {} } }, { autoResize: true });
607
345
  const chrome = {
608
346
  expanded: isExpanded,
@@ -612,14 +350,33 @@ export function bootstrapApp() {
612
350
  isExpanded = chrome.expanded;
613
351
  hideSummaryRow = chrome.hideSummaryRow;
614
352
  };
615
- // Widget state for cross-host persistence (survives page refresh)
616
- const widgetState = createWidgetStateStorage((v) => isPreviewStructuredContent(v) && typeof v.content === 'string');
353
+ const widgetState = createWidgetStateStorage((value) => isPreviewStructuredContent(value) && typeof value.content === 'string');
617
354
  const renderAndSync = (payload) => {
618
355
  if (payload) {
619
356
  widgetState.write(payload);
620
357
  }
621
358
  renderApp(container, payload, 'rendered', isExpanded);
622
359
  };
360
+ const syncFromPersistedWidgetState = () => {
361
+ const persistedPayload = widgetState.read();
362
+ if (!persistedPayload) {
363
+ return;
364
+ }
365
+ if (currentPayload
366
+ && currentPayload.filePath === persistedPayload.filePath
367
+ && stripReadStatusLine(currentPayload.content) === stripReadStatusLine(persistedPayload.content)) {
368
+ return;
369
+ }
370
+ renderAndSync(persistedPayload);
371
+ };
372
+ syncPayload = renderAndSync;
373
+ persistPayload = (payload) => {
374
+ widgetState.write(payload);
375
+ };
376
+ rerenderCurrent = () => {
377
+ renderApp(container, currentPayload, currentHtmlMode, isExpanded);
378
+ };
379
+ let pendingCachedPayload;
623
380
  let initialStateResolved = false;
624
381
  const resolveInitialState = (payload, message) => {
625
382
  if (initialStateResolved) {
@@ -627,47 +384,66 @@ export function bootstrapApp() {
627
384
  }
628
385
  initialStateResolved = true;
629
386
  if (payload) {
387
+ hostPayload = payload;
630
388
  renderAndSync(payload);
389
+ if (payload.fileType === 'markdown' && getCurrentDisplayMode() === 'fullscreen') {
390
+ void markdownController.requestEditMode(payload);
391
+ }
392
+ if (payload.fileType === 'markdown') {
393
+ void markdownController.refreshFromDisk(payload);
394
+ }
631
395
  return;
632
396
  }
633
397
  renderStatusState(container, message ?? 'No preview available for this response.');
634
398
  onRender?.();
635
399
  };
636
- // autoResize handles size reporting; onRender can be a no-op
637
400
  onRender = () => { };
638
- // Wire rpcCallTool through the App's callServerTool proxy
639
401
  rpcCallTool = (name, args) => (app.callServerTool({ name, arguments: args }));
640
- // Wire rpcUpdateContext through the App's updateModelContext
641
402
  rpcUpdateContext = (text) => {
642
403
  const params = text
643
404
  ? { content: [{ type: 'text', text }] }
644
405
  : { content: [] };
645
406
  app.updateModelContext(params).catch(() => {
646
- // Host may not support updateModelContext
407
+ // Host may not support updateModelContext.
647
408
  });
648
409
  };
410
+ openExternalLink = async (url) => {
411
+ const result = await app.openLink({ url });
412
+ return result.isError !== true;
413
+ };
414
+ requestDisplayMode = async (mode) => {
415
+ const result = await app.requestDisplayMode({ mode });
416
+ return typeof result.mode === 'string' ? result.mode : null;
417
+ };
649
418
  trackUiEvent = createUiEventTracker((name, args) => app.callServerTool({ name, arguments: args }), {
650
419
  component: 'file_preview',
651
420
  baseParams: { tool_name: 'read_file' },
652
421
  });
653
- // Register ALL handlers BEFORE connect
654
- app.onteardown = async () => {
655
- shellController?.dispose();
656
- return {};
657
- };
658
- app.ontoolinput = (_params) => {
659
- // Tool is executing – show loading state
422
+ app.ontoolinput = (params) => {
423
+ const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined;
424
+ if (!initialStateResolved
425
+ && pendingCachedPayload
426
+ && requestedPath
427
+ && pendingCachedPayload.filePath === requestedPath) {
428
+ const cached = pendingCachedPayload;
429
+ pendingCachedPayload = undefined;
430
+ resolveInitialState(cached);
431
+ return;
432
+ }
660
433
  renderLoadingState(container);
661
434
  onRender?.();
662
435
  };
663
436
  app.ontoolresult = (result) => {
437
+ pendingCachedPayload = undefined;
664
438
  const payload = extractRenderPayload(result);
665
439
  const message = extractToolText(result);
666
440
  if (!initialStateResolved) {
667
441
  if (payload) {
668
- renderLoadingState(container);
669
- onRender?.();
670
- window.setTimeout(() => resolveInitialState(payload), 120);
442
+ if (needsContentRead(payload)) {
443
+ void readAndResolvePayload(payload, (p) => resolveInitialState(getEffectiveIncomingPayload(p)));
444
+ return;
445
+ }
446
+ resolveInitialState(getEffectiveIncomingPayload(payload));
671
447
  return;
672
448
  }
673
449
  if (message) {
@@ -676,7 +452,13 @@ export function bootstrapApp() {
676
452
  return;
677
453
  }
678
454
  if (payload) {
679
- renderAndSync(payload);
455
+ if (needsContentRead(payload)) {
456
+ renderLoadingState(container);
457
+ void readAndResolvePayload(payload, (p) => renderAndSync(getEffectiveIncomingPayload(p)));
458
+ }
459
+ else {
460
+ renderAndSync(getEffectiveIncomingPayload(payload));
461
+ }
680
462
  }
681
463
  else if (message) {
682
464
  renderStatusState(container, message);
@@ -686,18 +468,86 @@ export function bootstrapApp() {
686
468
  app.ontoolcancelled = (params) => {
687
469
  resolveInitialState(undefined, params.reason ?? 'Tool was cancelled.');
688
470
  };
689
- // Connect to the host (defaults to window.parent via PostMessageTransport)
471
+ const handleVisibilitySync = () => {
472
+ if (document.visibilityState === 'visible') {
473
+ syncFromPersistedWidgetState();
474
+ }
475
+ };
476
+ const handleFocusSync = () => {
477
+ // Only sync cross-tab state if the page was hidden (tab switch).
478
+ // Simple focus changes within the same page should not trigger a re-render
479
+ // as it destroys the active editor.
480
+ if (document.visibilityState !== 'visible') {
481
+ syncFromPersistedWidgetState();
482
+ }
483
+ };
484
+ const teardown = () => {
485
+ shellController?.dispose();
486
+ shellController = undefined;
487
+ markdownController.disposeHandles();
488
+ selectionAbortController?.abort();
489
+ selectionAbortController = null;
490
+ document.removeEventListener('visibilitychange', handleVisibilitySync);
491
+ window.removeEventListener('focus', handleFocusSync);
492
+ };
493
+ document.addEventListener('visibilitychange', handleVisibilitySync);
494
+ window.addEventListener('focus', handleFocusSync);
495
+ app.onteardown = async () => {
496
+ teardown();
497
+ return {};
498
+ };
690
499
  void connectWithSharedHostContext({
691
500
  app,
692
501
  chrome,
693
- onContextApplied: syncChromeState,
694
- onConnected: () => {
695
- // Try to restore from persisted widget state (survives refresh on some hosts)
696
- const cachedPayload = widgetState.read();
697
- if (cachedPayload) {
698
- window.setTimeout(() => resolveInitialState(cachedPayload), 50);
502
+ onContextApplied: () => {
503
+ const previousDisplayMode = getCurrentDisplayMode();
504
+ syncChromeState();
505
+ currentHostContext = app.getHostContext();
506
+ const nextDisplayMode = getCurrentDisplayMode();
507
+ const displayModeChanged = previousDisplayMode !== nextDisplayMode;
508
+ // Clicking a display-mode button blurs the editor first, and the
509
+ // editor's onBlur handler already persists dirty drafts, so there
510
+ // is nothing additional to save here.
511
+ if (previousDisplayMode === 'fullscreen'
512
+ && nextDisplayMode === 'inline'
513
+ && currentPayload?.fileType === 'markdown') {
514
+ isExpanded = true;
515
+ chrome.expanded = true;
516
+ const restorePayload = inlinePayloadBeforeFullscreen ?? hostPayload;
517
+ const restoreWasPartial = restorePayload ? parseReadRange(restorePayload.content)?.isPartial === true : false;
518
+ if (restoreWasPartial && restorePayload) {
519
+ localPayloadOverride = restorePayload;
520
+ currentPayload = restorePayload;
521
+ widgetState.write(restorePayload);
522
+ void markdownController.handleInlineExitFromFullscreen(restorePayload).then((freshPayload) => {
523
+ if (freshPayload) {
524
+ currentPayload = freshPayload;
525
+ localPayloadOverride = freshPayload;
526
+ widgetState.write(freshPayload);
527
+ rerenderCurrent?.();
528
+ }
529
+ });
530
+ }
531
+ else {
532
+ void markdownController.handleInlineExitFromFullscreen();
533
+ }
534
+ inlinePayloadBeforeFullscreen = undefined;
535
+ }
536
+ if (previousDisplayMode !== 'fullscreen'
537
+ && nextDisplayMode === 'fullscreen'
538
+ && currentPayload?.fileType === 'markdown') {
539
+ inlinePayloadBeforeFullscreen = currentPayload;
540
+ if (parseReadRange(currentPayload.content)?.isPartial) {
541
+ void markdownController.requestEditMode(currentPayload);
542
+ }
543
+ }
544
+ if (initialStateResolved && displayModeChanged) {
545
+ rerenderCurrent?.();
699
546
  }
700
- // Fallback: if no tool data arrives, show a helpful status message
547
+ },
548
+ onConnected: () => {
549
+ currentHostContext = app.getHostContext();
550
+ pendingCachedPayload = widgetState.read() ?? undefined;
701
551
  window.setTimeout(() => {
702
552
  if (!initialStateResolved) {
703
553
  resolveInitialState(undefined, 'Preview unavailable after page refresh. Switch threads or re-run the tool.');
@@ -709,6 +559,6 @@ export function bootstrapApp() {
709
559
  onRender?.();
710
560
  });
711
561
  window.addEventListener('beforeunload', () => {
712
- shellController?.dispose();
562
+ teardown();
713
563
  }, { once: true });
714
564
  }