@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
@@ -0,0 +1,1040 @@
1
+ import { attachDocumentOutline, renderDocumentOutline } from '../document-outline.js';
2
+ import { getDocumentFullscreenAvailability, parseReadRange, shouldAutoLoadDocumentOnEnterFullscreen, stripReadStatusLine } from '../document-workspace.js';
3
+ import { assertSuccessfulEditBlockResult, extractRenderPayload, extractToolText } from '../payload-utils.js';
4
+ import { getAncestorDirectories, getParentDirectory, toPosixRelativePath } from '../path-utils.js';
5
+ import { mountMarkdownEditor, renderMarkdownEditorShell } from './editor.js';
6
+ import { resolveMarkdownLink } from './linking.js';
7
+ import { extractMarkdownOutline } from './outline.js';
8
+ import { getRenderedMarkdownCopyText } from './preview.js';
9
+ import { slugifyMarkdownHeading } from './slugify.js';
10
+ import { getFileExtensionForAnalytics } from '../payload-utils.js';
11
+ const MAX_EDIT_BLOCK_LINES = 40;
12
+ function areOutlineItemsEqual(left, right) {
13
+ if (left.length !== right.length) {
14
+ return false;
15
+ }
16
+ return left.every((item, index) => {
17
+ const other = right[index];
18
+ return item.id === other.id
19
+ && item.text === other.text
20
+ && item.level === other.level
21
+ && item.line === other.line;
22
+ });
23
+ }
24
+ function splitListingLines(text) {
25
+ return text.split('\n').map((line) => line.trim()).filter(Boolean);
26
+ }
27
+ function parseFileSearchResults(text) {
28
+ return text.split('\n')
29
+ .map((line) => line.trim())
30
+ .filter((line) => line.startsWith('📁 '))
31
+ .map((line) => line.slice(3).trim());
32
+ }
33
+ function stripMarkdownExtension(filePath) {
34
+ return filePath.replace(/\.md$/i, '');
35
+ }
36
+ function computeDiffHunks(oldLines, newLines) {
37
+ const oldLength = oldLines.length;
38
+ const newLength = newLines.length;
39
+ const dp = Array.from({ length: oldLength + 1 }, () => Array(newLength + 1).fill(0));
40
+ for (let i = 1; i <= oldLength; i += 1) {
41
+ for (let j = 1; j <= newLength; j += 1) {
42
+ dp[i][j] = oldLines[i - 1] === newLines[j - 1]
43
+ ? dp[i - 1][j - 1] + 1
44
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
45
+ }
46
+ }
47
+ const matches = [];
48
+ let oldIndex = oldLength;
49
+ let newIndex = newLength;
50
+ while (oldIndex > 0 && newIndex > 0) {
51
+ if (oldLines[oldIndex - 1] === newLines[newIndex - 1]) {
52
+ matches.unshift([oldIndex - 1, newIndex - 1]);
53
+ oldIndex -= 1;
54
+ newIndex -= 1;
55
+ }
56
+ else if (dp[oldIndex - 1][newIndex] >= dp[oldIndex][newIndex - 1]) {
57
+ oldIndex -= 1;
58
+ }
59
+ else {
60
+ newIndex -= 1;
61
+ }
62
+ }
63
+ const hunks = [];
64
+ let previousOld = 0;
65
+ let previousNew = 0;
66
+ for (const [matchOld, matchNew] of matches) {
67
+ if (matchOld > previousOld || matchNew > previousNew) {
68
+ hunks.push({ oldStart: previousOld, oldEnd: matchOld, newStart: previousNew, newEnd: matchNew });
69
+ }
70
+ previousOld = matchOld + 1;
71
+ previousNew = matchNew + 1;
72
+ }
73
+ if (previousOld < oldLength || previousNew < newLength) {
74
+ hunks.push({ oldStart: previousOld, oldEnd: oldLength, newStart: previousNew, newEnd: newLength });
75
+ }
76
+ return hunks;
77
+ }
78
+ function mergeCloseHunks(hunks, minGap) {
79
+ if (hunks.length <= 1) {
80
+ return hunks;
81
+ }
82
+ const merged = [{ ...hunks[0] }];
83
+ for (let index = 1; index < hunks.length; index += 1) {
84
+ const previous = merged[merged.length - 1];
85
+ const current = hunks[index];
86
+ if (current.oldStart - previous.oldEnd < minGap) {
87
+ previous.oldEnd = current.oldEnd;
88
+ previous.newEnd = current.newEnd;
89
+ continue;
90
+ }
91
+ merged.push({ ...current });
92
+ }
93
+ return merged;
94
+ }
95
+ function mergeLineRanges(ranges) {
96
+ const sorted = ranges
97
+ .map((range) => ({ fromLine: Math.max(1, Math.floor(range.fromLine)), toLine: Math.max(1, Math.floor(range.toLine)) }))
98
+ .sort((left, right) => left.fromLine - right.fromLine || left.toLine - right.toLine);
99
+ const merged = [];
100
+ for (const range of sorted) {
101
+ const normalized = {
102
+ fromLine: Math.min(range.fromLine, range.toLine),
103
+ toLine: Math.max(range.fromLine, range.toLine),
104
+ };
105
+ const previous = merged[merged.length - 1];
106
+ if (previous && normalized.fromLine <= previous.toLine + 1) {
107
+ previous.toLine = Math.max(previous.toLine, normalized.toLine);
108
+ }
109
+ else {
110
+ merged.push(normalized);
111
+ }
112
+ }
113
+ return merged;
114
+ }
115
+ function hunkIntersectsRanges(hunk, ranges) {
116
+ if (ranges.length === 0) {
117
+ return true;
118
+ }
119
+ const fromLine = Math.min(hunk.oldStart, hunk.newStart) + 1;
120
+ const toLine = Math.max(hunk.oldEnd, hunk.newEnd) + 1;
121
+ return ranges.some((range) => fromLine <= range.toLine && toLine >= range.fromLine);
122
+ }
123
+ function computeLineByLineHunks(oldLines, newLines) {
124
+ return computeAnchoredDiffHunks(oldLines, newLines, 0, oldLines.length, 0, newLines.length);
125
+ }
126
+ function computeAnchoredDiffHunks(oldLines, newLines, oldStart, oldEnd, newStart, newEnd) {
127
+ while (oldStart < oldEnd && newStart < newEnd && oldLines[oldStart] === newLines[newStart]) {
128
+ oldStart++;
129
+ newStart++;
130
+ }
131
+ while (oldStart < oldEnd && newStart < newEnd && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
132
+ oldEnd--;
133
+ newEnd--;
134
+ }
135
+ if (oldStart === oldEnd && newStart === newEnd) {
136
+ return [];
137
+ }
138
+ const oldLineCounts = new Map();
139
+ const newLineCounts = new Map();
140
+ for (let index = oldStart; index < oldEnd; index += 1) {
141
+ const current = oldLineCounts.get(oldLines[index]);
142
+ oldLineCounts.set(oldLines[index], { count: (current?.count ?? 0) + 1, index });
143
+ }
144
+ for (let index = newStart; index < newEnd; index += 1) {
145
+ const current = newLineCounts.get(newLines[index]);
146
+ newLineCounts.set(newLines[index], { count: (current?.count ?? 0) + 1, index });
147
+ }
148
+ for (let oldIndex = oldStart; oldIndex < oldEnd; oldIndex += 1) {
149
+ const oldEntry = oldLineCounts.get(oldLines[oldIndex]);
150
+ const newEntry = newLineCounts.get(oldLines[oldIndex]);
151
+ if (oldEntry?.count === 1 && newEntry?.count === 1) {
152
+ return [
153
+ ...computeAnchoredDiffHunks(oldLines, newLines, oldStart, oldIndex, newStart, newEntry.index),
154
+ ...computeAnchoredDiffHunks(oldLines, newLines, oldIndex + 1, oldEnd, newEntry.index + 1, newEnd),
155
+ ];
156
+ }
157
+ }
158
+ return [{ oldStart, oldEnd, newStart, newEnd }];
159
+ }
160
+ function splitOversizedEditBlock(oldText, newText) {
161
+ const oldLines = oldText.split('\n');
162
+ const newLines = newText.split('\n');
163
+ const blockCount = Math.ceil(Math.max(oldLines.length, newLines.length) / MAX_EDIT_BLOCK_LINES);
164
+ const blocks = [];
165
+ for (let blockIndex = 0; blockIndex < blockCount; blockIndex += 1) {
166
+ const oldStart = Math.floor((blockIndex * oldLines.length) / blockCount);
167
+ const oldEnd = Math.floor(((blockIndex + 1) * oldLines.length) / blockCount);
168
+ const newStart = Math.floor((blockIndex * newLines.length) / blockCount);
169
+ const newEnd = Math.floor(((blockIndex + 1) * newLines.length) / blockCount);
170
+ const old_string = oldLines.slice(oldStart, oldEnd).join('\n');
171
+ const new_string = newLines.slice(newStart, newEnd).join('\n');
172
+ if (old_string !== new_string) {
173
+ blocks.push({ old_string, new_string });
174
+ }
175
+ }
176
+ return blocks;
177
+ }
178
+ function splitOversizedEditBlocks(blocks) {
179
+ return blocks.flatMap((block) => {
180
+ const lineCount = Math.max(block.old_string.split('\n').length, block.new_string.split('\n').length);
181
+ return lineCount > MAX_EDIT_BLOCK_LINES
182
+ ? splitOversizedEditBlock(block.old_string, block.new_string)
183
+ : [block];
184
+ });
185
+ }
186
+ export function computeEditBlocks(oldText, newText, changedRanges = []) {
187
+ if (oldText === newText) {
188
+ return [];
189
+ }
190
+ const oldLines = oldText.split('\n');
191
+ const newLines = newText.split('\n');
192
+ const hunks = oldLines.length * newLines.length > 1000000
193
+ ? computeLineByLineHunks(oldLines, newLines)
194
+ : computeDiffHunks(oldLines, newLines);
195
+ if (hunks.length === 0) {
196
+ return [];
197
+ }
198
+ const context = 3;
199
+ const normalizedRanges = mergeLineRanges(changedRanges);
200
+ const merged = mergeCloseHunks(hunks, context * 2 + 1).filter((hunk) => hunkIntersectsRanges(hunk, normalizedRanges));
201
+ const blocks = merged.map((hunk) => {
202
+ const contextBefore = Math.max(0, hunk.oldStart - context);
203
+ const contextAfter = Math.min(oldLines.length, hunk.oldEnd + context);
204
+ const oldBlock = oldLines.slice(contextBefore, contextAfter).join('\n');
205
+ const newBlock = [
206
+ ...oldLines.slice(contextBefore, hunk.oldStart),
207
+ ...newLines.slice(hunk.newStart, hunk.newEnd),
208
+ ...oldLines.slice(hunk.oldEnd, contextAfter),
209
+ ].join('\n');
210
+ return { old_string: oldBlock, new_string: newBlock };
211
+ }).filter((block) => block.old_string !== block.new_string);
212
+ if (blocks.length === 1 && blocks[0].old_string === oldText && blocks[0].new_string === newText) {
213
+ return splitOversizedEditBlock(oldText, newText);
214
+ }
215
+ return splitOversizedEditBlocks(blocks);
216
+ }
217
+ function applyEditBlocksToText(text, blocks) {
218
+ return blocks.reduce((current, block) => current.replace(block.old_string, block.new_string), text);
219
+ }
220
+ function isToolErrorResult(value) {
221
+ return typeof value === 'object' && value !== null;
222
+ }
223
+ function isMissingFileErrorResult(result) {
224
+ if (!isToolErrorResult(result) || result.isError !== true) {
225
+ return false;
226
+ }
227
+ const message = extractToolText(result)?.toLowerCase() ?? '';
228
+ return message.includes('not found')
229
+ || message.includes('no such file')
230
+ || message.includes('enoent');
231
+ }
232
+ export function createMarkdownController(dependencies) {
233
+ let workspaceState;
234
+ let markdownEditorHandle;
235
+ let markdownTocHandle;
236
+ let autosaveTimer = null;
237
+ const AUTOSAVE_DEBOUNCE_MS = 1000;
238
+ function scheduleAutosave() {
239
+ if (autosaveTimer !== null) {
240
+ clearTimeout(autosaveTimer);
241
+ }
242
+ autosaveTimer = setTimeout(() => {
243
+ autosaveTimer = null;
244
+ void saveDocument();
245
+ }, AUTOSAVE_DEBOUNCE_MS);
246
+ }
247
+ function cancelAutosave() {
248
+ if (autosaveTimer !== null) {
249
+ clearTimeout(autosaveTimer);
250
+ autosaveTimer = null;
251
+ }
252
+ }
253
+ function disposeHandles() {
254
+ cancelAutosave();
255
+ markdownEditorHandle?.destroy();
256
+ markdownEditorHandle = undefined;
257
+ markdownTocHandle?.dispose();
258
+ markdownTocHandle = undefined;
259
+ }
260
+ function clear() {
261
+ workspaceState = undefined;
262
+ disposeHandles();
263
+ }
264
+ function readPayloadContent(payload) {
265
+ return stripReadStatusLine(payload.content);
266
+ }
267
+ function syncStateFromContent(state, content, options = {}) {
268
+ const nextDraftContent = options.keepDraft ? state.draftContent : content;
269
+ state.sourceContent = content;
270
+ state.fullDocumentContent = content;
271
+ state.draftContent = nextDraftContent;
272
+ state.outline = extractMarkdownOutline(content);
273
+ state.dirty = nextDraftContent !== content;
274
+ state.dirtyLineRanges = [];
275
+ state.fileDeleted = false;
276
+ if (!state.outline.some((item) => item.id === state.activeHeadingId)) {
277
+ state.activeHeadingId = state.outline[0]?.id ?? null;
278
+ }
279
+ }
280
+ async function callReadFile(filePath, length, offset) {
281
+ const rawResult = await dependencies.callTool?.('read_file', {
282
+ path: filePath,
283
+ ...(typeof length === 'number' ? { offset: offset ?? 0, length } : {}),
284
+ });
285
+ return { rawResult, payload: extractRenderPayload(rawResult) ?? null };
286
+ }
287
+ async function readPayload(filePath, length, offset) {
288
+ return (await callReadFile(filePath, length, offset)).payload;
289
+ }
290
+ async function ensureCompletePayload(payload) {
291
+ const range = parseReadRange(payload.content);
292
+ if (!range?.isPartial) {
293
+ return payload;
294
+ }
295
+ return (await readPayload(payload.filePath, range.totalLines)) ?? payload;
296
+ }
297
+ async function readCompletePayload(filePath) {
298
+ const payload = await readPayload(filePath);
299
+ if (!payload) {
300
+ return null;
301
+ }
302
+ return ensureCompletePayload(payload);
303
+ }
304
+ function getState(payload) {
305
+ const cleanedContent = stripReadStatusLine(payload.content);
306
+ if (!workspaceState || workspaceState.filePath !== payload.filePath || workspaceState.sourceContent !== cleanedContent) {
307
+ const outline = extractMarkdownOutline(cleanedContent);
308
+ workspaceState = {
309
+ filePath: payload.filePath,
310
+ sourceContent: cleanedContent,
311
+ fullDocumentContent: cleanedContent,
312
+ draftContent: cleanedContent,
313
+ outline,
314
+ mode: 'edit',
315
+ dirty: false,
316
+ dirtyLineRanges: [],
317
+ activeHeadingId: outline[0]?.id ?? null,
318
+ pendingAnchor: null,
319
+ notice: null,
320
+ error: null,
321
+ saving: false,
322
+ loadingDocument: false,
323
+ editorView: 'markdown',
324
+ editorScrollTop: 0,
325
+ saveIndicator: 'idle',
326
+ fileDeleted: false,
327
+ };
328
+ }
329
+ return workspaceState;
330
+ }
331
+ function isUndoAvailable(state) {
332
+ return state.draftContent !== state.fullDocumentContent;
333
+ }
334
+ function buildBody(payload) {
335
+ const state = getState(payload);
336
+ const outline = state.outline;
337
+ const isFullscreen = dependencies.getCurrentDisplayMode() === 'fullscreen';
338
+ const tocHtml = isFullscreen ? renderDocumentOutline(outline, state.activeHeadingId) : '';
339
+ if (!state.activeHeadingId && outline.length > 0) {
340
+ state.activeHeadingId = outline[0].id;
341
+ }
342
+ const notice = [state.error, state.notice]
343
+ .find((value) => typeof value === 'string' && value.trim().length > 0);
344
+ return {
345
+ notice,
346
+ html: `
347
+ <div class="panel-content markdown-content markdown-content--workspace">
348
+ <div class="markdown-workspace markdown-workspace--edit${tocHtml ? ' markdown-workspace--with-toc' : ''}">
349
+ ${tocHtml}
350
+ <section class="markdown-workspace-main markdown-workspace-main--editor">
351
+ ${renderMarkdownEditorShell({ view: state.editorView })}
352
+ </section>
353
+ </div>
354
+ </div>
355
+ `,
356
+ };
357
+ }
358
+ async function resolveLinkSearchRoot(filePath) {
359
+ const ancestors = getAncestorDirectories(filePath);
360
+ const markers = new Set(['[DIR] .git', '[DIR] .obsidian', '[FILE] package.json', '[FILE] pnpm-workspace.yaml', '[FILE] turbo.json']);
361
+ for (const ancestor of ancestors) {
362
+ try {
363
+ const result = await dependencies.callTool?.('list_directory', { path: ancestor, depth: 1 });
364
+ const text = extractToolText(result) ?? '';
365
+ const entries = splitListingLines(text);
366
+ if (entries.some((entry) => markers.has(entry))) {
367
+ return ancestor;
368
+ }
369
+ }
370
+ catch {
371
+ // Ignore and continue up the tree.
372
+ }
373
+ }
374
+ return getParentDirectory(filePath);
375
+ }
376
+ async function searchLinkTargets(filePath, query) {
377
+ const trimmedQuery = query.trim();
378
+ if (trimmedQuery.length === 0) {
379
+ return [];
380
+ }
381
+ const rootPath = await resolveLinkSearchRoot(filePath);
382
+ const result = await dependencies.callTool?.('start_search', {
383
+ path: rootPath,
384
+ pattern: trimmedQuery,
385
+ searchType: 'files',
386
+ filePattern: '*.md',
387
+ maxResults: 20,
388
+ earlyTermination: false,
389
+ literalSearch: true,
390
+ });
391
+ const text = extractToolText(result) ?? '';
392
+ const filePaths = parseFileSearchResults(text);
393
+ const currentDirectory = getParentDirectory(filePath);
394
+ return filePaths.map((targetPath) => {
395
+ const normalized = targetPath.replace(/\\/g, '/');
396
+ const fileName = normalized.split('/').pop() ?? normalized;
397
+ const title = stripMarkdownExtension(fileName);
398
+ const relativePath = toPosixRelativePath(currentDirectory, normalized);
399
+ const wikiPath = stripMarkdownExtension(relativePath.startsWith('./') ? relativePath.slice(2) : relativePath);
400
+ return {
401
+ path: normalized,
402
+ title,
403
+ wikiPath,
404
+ relativePath,
405
+ };
406
+ });
407
+ }
408
+ async function loadLinkHeadings(currentPayloadPath, targetPath) {
409
+ if (targetPath === currentPayloadPath && workspaceState) {
410
+ return workspaceState.outline.map((item) => ({ id: item.id, text: item.text }));
411
+ }
412
+ const payload = await readCompletePayload(targetPath);
413
+ if (!payload) {
414
+ return [];
415
+ }
416
+ return extractMarkdownOutline(readPayloadContent(payload)).map((item) => ({ id: item.id, text: item.text }));
417
+ }
418
+ function findHeading(anchor) {
419
+ const trimmedAnchor = anchor.trim();
420
+ if (!trimmedAnchor) {
421
+ return null;
422
+ }
423
+ return document.getElementById(trimmedAnchor) ?? document.getElementById(slugifyMarkdownHeading(trimmedAnchor));
424
+ }
425
+ function scrollHeadingIntoView(anchor) {
426
+ const heading = findHeading(anchor);
427
+ if (!heading) {
428
+ return false;
429
+ }
430
+ const scrollParents = [];
431
+ let current = heading.parentElement;
432
+ while (current) {
433
+ const style = window.getComputedStyle(current);
434
+ const overflowY = style.overflowY;
435
+ const isScrollable = (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay')
436
+ && current.scrollHeight > current.clientHeight;
437
+ if (isScrollable) {
438
+ scrollParents.push(current);
439
+ }
440
+ current = current.parentElement;
441
+ }
442
+ heading.scrollIntoView({ block: 'start', inline: 'nearest' });
443
+ for (const parent of scrollParents) {
444
+ const parentRect = parent.getBoundingClientRect();
445
+ const headingRect = heading.getBoundingClientRect();
446
+ const nextTop = Math.max(parent.scrollTop + (headingRect.top - parentRect.top) - 24, 0);
447
+ parent.scrollTop = nextTop;
448
+ }
449
+ const rootScroller = document.scrollingElement;
450
+ if (rootScroller) {
451
+ const rootRectTop = heading.getBoundingClientRect().top;
452
+ const nextRootTop = Math.max(rootScroller.scrollTop + rootRectTop - 24, 0);
453
+ rootScroller.scrollTop = nextRootTop;
454
+ }
455
+ heading.setAttribute('tabindex', '-1');
456
+ heading.focus({ preventScroll: true });
457
+ if (workspaceState) {
458
+ workspaceState.activeHeadingId = heading.id || slugifyMarkdownHeading(anchor);
459
+ }
460
+ return true;
461
+ }
462
+ function applyPendingAnchor() {
463
+ const pendingAnchor = workspaceState?.pendingAnchor;
464
+ if (!workspaceState || !pendingAnchor) {
465
+ return;
466
+ }
467
+ workspaceState.pendingAnchor = null;
468
+ if (!scrollHeadingIntoView(pendingAnchor)) {
469
+ workspaceState.error = `Heading not found: ${pendingAnchor}`;
470
+ dependencies.rerender();
471
+ }
472
+ }
473
+ function flashSaveStatus(label, statusClass, timeoutMs, beforeClear) {
474
+ dependencies.updateSaveStatus(label, statusClass);
475
+ window.setTimeout(() => {
476
+ if (beforeClear && !beforeClear()) {
477
+ return;
478
+ }
479
+ dependencies.updateSaveStatus('', '');
480
+ }, timeoutMs);
481
+ }
482
+ async function refreshFromDisk(payload) {
483
+ try {
484
+ const range = parseReadRange(payload.content);
485
+ const { rawResult, payload: freshPayload } = range?.isPartial
486
+ ? await callReadFile(payload.filePath, range.toLine - range.fromLine + 1, range.readOffset)
487
+ : await callReadFile(payload.filePath);
488
+ if (!freshPayload) {
489
+ if (isMissingFileErrorResult(rawResult)) {
490
+ if (workspaceState) {
491
+ workspaceState.fileDeleted = true;
492
+ }
493
+ dependencies.updateSaveStatus('File deleted', 'saved');
494
+ }
495
+ return;
496
+ }
497
+ const freshContent = readPayloadContent(freshPayload);
498
+ const currentContent = readPayloadContent(payload);
499
+ if (freshContent === currentContent) {
500
+ return;
501
+ }
502
+ // refreshFromDisk only runs at mount (no file watcher in this app),
503
+ // so disk-vs-payload mismatch means the host sent a stale cached
504
+ // payload — trust the disk read and reload silently.
505
+ dependencies.storePayloadOverride(freshPayload);
506
+ workspaceState = undefined;
507
+ dependencies.rerender();
508
+ }
509
+ catch {
510
+ // Silently fall back to host payload.
511
+ }
512
+ }
513
+ async function loadFullDocument(payload, options = {}) {
514
+ const state = getState(payload);
515
+ const range = parseReadRange(payload.content);
516
+ if (!range?.isPartial) {
517
+ if (options.keepEditMode) {
518
+ state.mode = 'edit';
519
+ state.editorView = 'markdown';
520
+ state.notice = null;
521
+ state.error = null;
522
+ state.draftContent = state.sourceContent;
523
+ state.dirty = false;
524
+ dependencies.rerender();
525
+ }
526
+ return;
527
+ }
528
+ state.loadingDocument = true;
529
+ state.notice = 'Loading full document…';
530
+ state.error = null;
531
+ dependencies.rerender();
532
+ try {
533
+ const nextPayload = await readPayload(payload.filePath, range.totalLines);
534
+ if (!nextPayload) {
535
+ state.error = 'Failed to load the full document.';
536
+ state.notice = null;
537
+ state.loadingDocument = false;
538
+ dependencies.rerender();
539
+ return;
540
+ }
541
+ dependencies.syncPayload?.(nextPayload);
542
+ const nextState = getState(nextPayload);
543
+ nextState.loadingDocument = false;
544
+ nextState.notice = null;
545
+ nextState.error = null;
546
+ syncStateFromContent(nextState, nextState.sourceContent);
547
+ if (options.keepEditMode) {
548
+ nextState.mode = 'edit';
549
+ nextState.editorView = 'markdown';
550
+ dependencies.rerender();
551
+ }
552
+ }
553
+ catch {
554
+ state.loadingDocument = false;
555
+ state.notice = null;
556
+ state.error = 'Failed to load the full document.';
557
+ dependencies.rerender();
558
+ }
559
+ }
560
+ async function navigateLink(payload, href) {
561
+ const state = getState(payload);
562
+ if (state.dirty) {
563
+ const shouldDiscard = window.confirm('Discard unsaved changes and follow this link?');
564
+ if (!shouldDiscard) {
565
+ return;
566
+ }
567
+ }
568
+ const resolvedLink = resolveMarkdownLink(payload.filePath, href);
569
+ state.notice = null;
570
+ state.error = null;
571
+ if (resolvedLink.kind === 'external' && resolvedLink.url) {
572
+ const opened = await dependencies.openExternalLink?.(resolvedLink.url);
573
+ if (!opened) {
574
+ try {
575
+ window.open(resolvedLink.url, '_blank', 'noopener');
576
+ }
577
+ catch { /* sandbox may block */ }
578
+ }
579
+ return;
580
+ }
581
+ if (resolvedLink.kind === 'anchor' && resolvedLink.anchor) {
582
+ if (!scrollHeadingIntoView(resolvedLink.anchor) && workspaceState) {
583
+ workspaceState.error = `Heading not found: ${resolvedLink.anchor}`;
584
+ dependencies.rerender();
585
+ }
586
+ return;
587
+ }
588
+ if (resolvedLink.kind === 'file' && resolvedLink.targetPath) {
589
+ const hostHandled = await dependencies.openExternalLink?.(resolvedLink.targetPath);
590
+ if (hostHandled) {
591
+ return;
592
+ }
593
+ const nextPayload = await readPayload(resolvedLink.targetPath);
594
+ if (!nextPayload) {
595
+ if (workspaceState) {
596
+ workspaceState.error = `Unable to open ${resolvedLink.targetPath}.`;
597
+ dependencies.rerender();
598
+ }
599
+ return;
600
+ }
601
+ dependencies.syncPayload?.(nextPayload);
602
+ const nextState = getState(nextPayload);
603
+ nextState.pendingAnchor = resolvedLink.anchor ?? null;
604
+ nextState.error = null;
605
+ nextState.notice = null;
606
+ dependencies.rerender();
607
+ }
608
+ }
609
+ async function requestEditMode(payload) {
610
+ const state = getState(payload);
611
+ state.error = null;
612
+ state.notice = null;
613
+ if (shouldAutoLoadDocumentOnEnterFullscreen(payload.content)) {
614
+ await loadFullDocument(payload, { keepEditMode: true });
615
+ return;
616
+ }
617
+ state.mode = 'edit';
618
+ state.draftContent = state.fullDocumentContent;
619
+ state.dirty = false;
620
+ state.editorView = 'markdown';
621
+ dependencies.setExpanded(true);
622
+ dependencies.rerender();
623
+ }
624
+ async function requestFullscreen() {
625
+ const fullscreenAvailability = getDocumentFullscreenAvailability({
626
+ availableDisplayModes: dependencies.getAvailableDisplayModes(),
627
+ });
628
+ if (!fullscreenAvailability.canFullscreen) {
629
+ return false;
630
+ }
631
+ const nextMode = await dependencies.requestDisplayMode?.('fullscreen');
632
+ return nextMode === 'fullscreen';
633
+ }
634
+ function revertEditing() {
635
+ if (!workspaceState) {
636
+ return;
637
+ }
638
+ const filePath = workspaceState.filePath;
639
+ workspaceState.draftContent = workspaceState.fullDocumentContent;
640
+ workspaceState.dirty = false;
641
+ workspaceState.dirtyLineRanges = [];
642
+ workspaceState.error = null;
643
+ workspaceState.notice = null;
644
+ dependencies.rerender();
645
+ flashSaveStatus('Reverted', 'saved', 1500);
646
+ dependencies.trackUiEvent?.('markdown_reverted', {
647
+ file_extension: getFileExtensionForAnalytics(filePath),
648
+ });
649
+ }
650
+ async function saveDocument() {
651
+ if (!workspaceState || workspaceState.saving || !workspaceState.dirty || workspaceState.fileDeleted) {
652
+ return;
653
+ }
654
+ cancelAutosave();
655
+ const state = workspaceState;
656
+ state.saving = true;
657
+ state.saveIndicator = 'saving';
658
+ state.error = null;
659
+ state.notice = null;
660
+ try {
661
+ const blocks = computeEditBlocks(state.fullDocumentContent, state.draftContent, state.dirtyLineRanges);
662
+ if (blocks.length === 0) {
663
+ state.saving = false;
664
+ state.saveIndicator = 'idle';
665
+ state.dirty = false;
666
+ state.dirtyLineRanges = [];
667
+ return;
668
+ }
669
+ // Try each hunk independently. Previously the loop threw on the
670
+ // first soft-failure, which left earlier hunks already written to
671
+ // disk while the UI claimed "Save failed" — making it look like the
672
+ // editor had silently overwritten external changes. Now we track
673
+ // per-hunk outcomes so we can give the user an honest accounting.
674
+ let appliedCount = 0;
675
+ const skippedHunks = [];
676
+ let lastHardError = null;
677
+ for (const block of blocks) {
678
+ try {
679
+ const editResult = await dependencies.callTool?.('edit_block', {
680
+ file_path: state.filePath,
681
+ old_string: block.old_string,
682
+ new_string: block.new_string,
683
+ expected_replacements: 1,
684
+ });
685
+ assertSuccessfulEditBlockResult(editResult);
686
+ appliedCount++;
687
+ }
688
+ catch (hunkError) {
689
+ // A per-hunk failure is almost always "old_string not on
690
+ // disk" because the file changed there. Record it and keep
691
+ // going so other hunks still get their chance.
692
+ skippedHunks.push(block);
693
+ lastHardError = hunkError;
694
+ }
695
+ }
696
+ if (skippedHunks.length > 0) {
697
+ // Partial (or total) failure. Let the catch branch take it —
698
+ // throw an error carrying counts so the catch can decide how
699
+ // to communicate and how to resync baseline vs. disk.
700
+ const err = new Error(`${appliedCount} of ${blocks.length} edit${blocks.length === 1 ? '' : 's'} applied; ` +
701
+ `${skippedHunks.length} could not land because the text changed on disk.`);
702
+ err.appliedCount = appliedCount;
703
+ err.skippedCount = skippedHunks.length;
704
+ err.totalCount = blocks.length;
705
+ err.underlyingError = lastHardError;
706
+ throw err;
707
+ }
708
+ const savedContent = applyEditBlocksToText(state.fullDocumentContent, blocks);
709
+ state.fullDocumentContent = savedContent;
710
+ state.sourceContent = savedContent;
711
+ state.draftContent = savedContent;
712
+ state.outline = extractMarkdownOutline(state.sourceContent);
713
+ state.dirty = false;
714
+ state.dirtyLineRanges = [];
715
+ state.saving = false;
716
+ state.saveIndicator = 'saved';
717
+ if (!state.outline.some((item) => item.id === state.activeHeadingId)) {
718
+ state.activeHeadingId = state.outline[0]?.id ?? null;
719
+ }
720
+ const currentPayload = dependencies.getCurrentPayload();
721
+ if (currentPayload) {
722
+ const statusLineMatch = currentPayload.content.match(/^(\[Reading [^\]]+\]\r?\n(?:\r?\n)?)/);
723
+ const statusLine = statusLineMatch?.[1] ?? '';
724
+ dependencies.storePayloadOverride({ ...currentPayload, content: statusLine + savedContent });
725
+ }
726
+ const revert = document.getElementById('revert-markdown');
727
+ if (revert) {
728
+ revert.disabled = !isUndoAvailable(state);
729
+ }
730
+ flashSaveStatus('Saved', 'saved', 1800, () => {
731
+ if (!state.dirty && !state.saving) {
732
+ state.saveIndicator = 'idle';
733
+ return true;
734
+ }
735
+ return false;
736
+ });
737
+ dependencies.trackUiEvent?.('markdown_saved', {
738
+ file_extension: getFileExtensionForAnalytics(state.filePath),
739
+ blocks: blocks.length,
740
+ });
741
+ }
742
+ catch (error) {
743
+ state.saving = false;
744
+ state.saveIndicator = 'idle';
745
+ // Pull per-hunk counts from the synthetic error thrown by the save
746
+ // loop, when this catch is reached via a partial/total hunk failure.
747
+ const errWithCounts = error;
748
+ const appliedCount = typeof errWithCounts.appliedCount === 'number' ? errWithCounts.appliedCount : 0;
749
+ const skippedCount = typeof errWithCounts.skippedCount === 'number' ? errWithCounts.skippedCount : 0;
750
+ const totalCount = typeof errWithCounts.totalCount === 'number' ? errWithCounts.totalCount : 0;
751
+ const isPartialSuccess = appliedCount > 0 && skippedCount > 0;
752
+ const isTotalFailure = appliedCount === 0 && skippedCount > 0;
753
+ const freshPayload = await readCompletePayload(state.filePath).catch(() => null);
754
+ let reloadedFromDisk = false;
755
+ let freshContentForDialog = null;
756
+ if (freshPayload) {
757
+ const freshContent = readPayloadContent(freshPayload);
758
+ if (freshContent !== state.fullDocumentContent) {
759
+ syncStateFromContent(state, freshContent, { keepDraft: true });
760
+ dependencies.storePayloadOverride(freshPayload);
761
+ reloadedFromDisk = true;
762
+ freshContentForDialog = freshContent;
763
+ }
764
+ }
765
+ state.notice = null;
766
+ if (isPartialSuccess) {
767
+ // Some hunks landed on disk, some didn't. The user would be
768
+ // misled by a "Save failed" message — and would be misled by
769
+ // a conflict dialog claiming nothing was saved. Tell them the
770
+ // truth: N saved, M skipped, with the skipped lines preserved
771
+ // in their draft so they can re-try or edit around the
772
+ // external change.
773
+ state.error = (`${appliedCount} of ${totalCount} edit${totalCount === 1 ? '' : 's'} saved. ` +
774
+ `${skippedCount} ${skippedCount === 1 ? 'edit' : 'edits'} did not apply because that text changed on disk — ` +
775
+ `your draft still has them; save again to merge.`);
776
+ dependencies.rerender();
777
+ flashSaveStatus('Saved (partial)', 'saved', 3000);
778
+ dependencies.trackUiEvent?.('markdown_save_partial', {
779
+ file_extension: getFileExtensionForAnalytics(state.filePath),
780
+ applied: appliedCount,
781
+ skipped: skippedCount,
782
+ total: totalCount,
783
+ });
784
+ }
785
+ else if (isTotalFailure && reloadedFromDisk && dependencies.showConflictDialog && freshContentForDialog !== null) {
786
+ // No hunks landed and disk has changed. Genuine conflict — show
787
+ // the modal so the user can pick keep-mine / use-disk.
788
+ const savedFreshContent = freshContentForDialog;
789
+ const normalized = state.filePath.replace(/\\/g, '/');
790
+ const displayName = normalized.split('/').pop() || state.filePath;
791
+ state.error = null;
792
+ dependencies.rerender();
793
+ dependencies.trackUiEvent?.('markdown_save_conflict_shown', {
794
+ file_extension: getFileExtensionForAnalytics(state.filePath),
795
+ });
796
+ dependencies.showConflictDialog({
797
+ fileName: displayName,
798
+ onUseDiskVersion: () => {
799
+ if (workspaceState === state) {
800
+ syncStateFromContent(state, savedFreshContent);
801
+ dependencies.rerender();
802
+ }
803
+ dependencies.trackUiEvent?.('markdown_save_conflict_resolved', {
804
+ file_extension: getFileExtensionForAnalytics(state.filePath),
805
+ action: 'use_disk',
806
+ });
807
+ },
808
+ onSaveMyChanges: () => {
809
+ dependencies.trackUiEvent?.('markdown_save_conflict_resolved', {
810
+ file_extension: getFileExtensionForAnalytics(state.filePath),
811
+ action: 'save_mine',
812
+ });
813
+ // Re-run saveDocument. computeEditBlocks will diff against
814
+ // the fresh sourceContent that keepDraft: true left in place,
815
+ // so hunks the user actually modified win over disk on
816
+ // those specific lines and disk-only changes elsewhere are
817
+ // preserved.
818
+ void saveDocument();
819
+ },
820
+ onCancel: () => {
821
+ if (workspaceState === state) {
822
+ state.error = 'File changed on disk. Save again to merge your edits, or reopen to discard them.';
823
+ dependencies.rerender();
824
+ }
825
+ dependencies.trackUiEvent?.('markdown_save_conflict_resolved', {
826
+ file_extension: getFileExtensionForAnalytics(state.filePath),
827
+ action: 'dismissed',
828
+ });
829
+ },
830
+ });
831
+ }
832
+ else {
833
+ // Fallback: unexpected error that isn't a per-hunk soft-fail
834
+ // (e.g. read_file failure during resync, or an exception before
835
+ // the save loop started). Use a generic inline message.
836
+ state.error = reloadedFromDisk
837
+ ? 'File changed on disk. Save again to merge your edits, or reopen the file to discard them.'
838
+ : error instanceof Error ? error.message : 'Save failed.';
839
+ dependencies.rerender();
840
+ flashSaveStatus('Save failed', 'saving', 3000);
841
+ }
842
+ dependencies.trackUiEvent?.('markdown_save_failed', {
843
+ file_extension: getFileExtensionForAnalytics(state.filePath),
844
+ reloaded_from_disk: reloadedFromDisk,
845
+ applied: appliedCount,
846
+ skipped: skippedCount,
847
+ total: totalCount,
848
+ });
849
+ }
850
+ }
851
+ function setEditorView(payload, view) {
852
+ const state = getState(payload);
853
+ const wrapper = document.querySelector('.panel-content-wrapper');
854
+ state.editorScrollTop = wrapper?.scrollTop ?? 0;
855
+ const previousView = state.editorView;
856
+ state.editorView = view;
857
+ state.notice = null;
858
+ state.error = null;
859
+ dependencies.rerender();
860
+ if (previousView !== view) {
861
+ dependencies.trackUiEvent?.('markdown_view_toggled', {
862
+ file_extension: getFileExtensionForAnalytics(payload.filePath),
863
+ view,
864
+ });
865
+ }
866
+ if (typeof state.editorScrollTop === 'number') {
867
+ window.requestAnimationFrame(() => {
868
+ const nextWrapper = document.querySelector('.panel-content-wrapper');
869
+ if (nextWrapper) {
870
+ nextWrapper.scrollTop = state.editorScrollTop;
871
+ }
872
+ });
873
+ }
874
+ }
875
+ function attachHandlers(payload) {
876
+ const state = getState(payload);
877
+ const wrapper = document.querySelector('.panel-content-wrapper');
878
+ const outline = state.outline;
879
+ const fileExtension = getFileExtensionForAnalytics(payload.filePath);
880
+ let editStartedFired = false;
881
+ {
882
+ const editorRoot = document.getElementById('markdown-editor-root');
883
+ if (editorRoot) {
884
+ markdownEditorHandle = mountMarkdownEditor({
885
+ target: editorRoot,
886
+ value: state.draftContent,
887
+ view: state.editorView,
888
+ initialScrollTop: state.editorScrollTop,
889
+ currentFilePath: payload.filePath,
890
+ searchLinks: (query) => searchLinkTargets(payload.filePath, query),
891
+ loadHeadings: (targetPath) => loadLinkHeadings(payload.filePath, targetPath),
892
+ onChange: (value, editRanges) => {
893
+ if (value === state.draftContent) {
894
+ return;
895
+ }
896
+ state.draftContent = value;
897
+ state.dirty = value !== state.fullDocumentContent;
898
+ if (state.dirty) {
899
+ const nextRanges = editRanges && editRanges.length > 0
900
+ ? editRanges
901
+ : [{ fromLine: 1, toLine: value.split('\n').length }];
902
+ state.dirtyLineRanges = mergeLineRanges([
903
+ ...state.dirtyLineRanges,
904
+ ...nextRanges,
905
+ ]);
906
+ }
907
+ else {
908
+ state.dirtyLineRanges = [];
909
+ }
910
+ if (state.dirty && !editStartedFired) {
911
+ editStartedFired = true;
912
+ dependencies.trackUiEvent?.('markdown_edit_started', {
913
+ file_extension: fileExtension,
914
+ view: state.editorView,
915
+ });
916
+ }
917
+ if (state.dirty) {
918
+ scheduleAutosave();
919
+ }
920
+ const nextOutline = extractMarkdownOutline(value);
921
+ if (!areOutlineItemsEqual(state.outline, nextOutline)) {
922
+ state.outline = nextOutline;
923
+ if (!state.outline.some((item) => item.id === state.activeHeadingId)) {
924
+ state.activeHeadingId = state.outline[0]?.id ?? null;
925
+ }
926
+ markdownTocHandle?.refresh(state.outline, state.activeHeadingId);
927
+ }
928
+ if (state.dirty && state.saveIndicator === 'saved') {
929
+ state.saveIndicator = 'idle';
930
+ }
931
+ const revert = document.getElementById('revert-markdown');
932
+ if (revert) {
933
+ revert.disabled = !isUndoAvailable(state);
934
+ }
935
+ },
936
+ onBlur: () => {
937
+ if (!state.dirty) {
938
+ return;
939
+ }
940
+ cancelAutosave();
941
+ void saveDocument();
942
+ },
943
+ });
944
+ markdownEditorHandle.focus();
945
+ }
946
+ const revertButton = document.getElementById('revert-markdown');
947
+ revertButton?.addEventListener('click', () => {
948
+ revertEditing();
949
+ });
950
+ const rawModeButton = document.getElementById('markdown-mode-raw');
951
+ rawModeButton?.addEventListener('click', () => {
952
+ setEditorView(payload, 'raw');
953
+ });
954
+ const previewModeButton = document.getElementById('markdown-mode-markdown');
955
+ previewModeButton?.addEventListener('click', () => {
956
+ setEditorView(payload, 'markdown');
957
+ });
958
+ }
959
+ const expandButton = document.getElementById('expand-fullscreen');
960
+ expandButton?.addEventListener('click', () => {
961
+ void requestFullscreen();
962
+ });
963
+ if (wrapper) {
964
+ wrapper.addEventListener('click', (event) => {
965
+ const target = event.target;
966
+ const link = target?.closest('a[href]');
967
+ if (!link || !link.closest('.markdown-doc')) {
968
+ return;
969
+ }
970
+ const href = link.getAttribute('href');
971
+ if (!href) {
972
+ return;
973
+ }
974
+ event.preventDefault();
975
+ void navigateLink(payload, href);
976
+ });
977
+ }
978
+ const tocShell = document.querySelector('.document-outline-shell');
979
+ if (tocShell && wrapper) {
980
+ markdownTocHandle = attachDocumentOutline({
981
+ shell: tocShell,
982
+ outline,
983
+ scrollContainer: wrapper,
984
+ onSelect: (headingId) => {
985
+ const selectedHeading = state.outline.find((item) => item.id === headingId);
986
+ if (selectedHeading && typeof selectedHeading.line === 'number') {
987
+ markdownEditorHandle?.revealLine(selectedHeading.line, selectedHeading.id);
988
+ state.activeHeadingId = selectedHeading.id;
989
+ }
990
+ },
991
+ }) ?? undefined;
992
+ }
993
+ window.setTimeout(() => {
994
+ applyPendingAnchor();
995
+ }, 0);
996
+ }
997
+ function getCopyText(payload) {
998
+ const state = getState(payload);
999
+ const source = state.draftContent;
1000
+ return state.editorView === 'raw'
1001
+ ? source
1002
+ : (getRenderedMarkdownCopyText(source) || source);
1003
+ }
1004
+ async function handleInlineExitFromFullscreen(originalPayload) {
1005
+ const wasDirty = workspaceState?.saveIndicator === 'saved' || workspaceState?.dirty;
1006
+ if (workspaceState) {
1007
+ workspaceState.notice = null;
1008
+ workspaceState.editorView = 'markdown';
1009
+ }
1010
+ if (wasDirty && originalPayload) {
1011
+ const range = parseReadRange(originalPayload.content);
1012
+ if (range?.isPartial) {
1013
+ const freshPayload = await readPayload(originalPayload.filePath, range.toLine - range.fromLine + 1, range.readOffset);
1014
+ if (freshPayload) {
1015
+ return freshPayload;
1016
+ }
1017
+ }
1018
+ }
1019
+ return undefined;
1020
+ }
1021
+ return {
1022
+ attachHandlers,
1023
+ buildBody,
1024
+ clear,
1025
+ disposeHandles,
1026
+ ensureCompletePayload,
1027
+ getCopyText,
1028
+ getState,
1029
+ handleInlineExitFromFullscreen,
1030
+ isUndoAvailable,
1031
+ readCompletePayload,
1032
+ readPayload,
1033
+ readPayloadContent,
1034
+ refreshFromDisk,
1035
+ requestEditMode,
1036
+ requestFullscreen,
1037
+ saveDocument,
1038
+ setEditorView,
1039
+ };
1040
+ }