collabmd 0.1.44 → 0.1.45

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 (170) hide show
  1. package/dist/client/assets/{chunk-AXR3VHVW-0GTk_evH.js → chunk-ERSWUTIN-ME9RVNel.js} +3 -3
  2. package/dist/client/assets/{chunk-CZQZEIFA-DT3N9fo7.js → chunk-YGJVY4SA-CjSe7Bx7.js} +2 -2
  3. package/dist/client/assets/{comment-threads-D-iKphIp.js → comment-threads-DDegmaFO.js} +1 -1
  4. package/dist/client/assets/drawio-editor-ClE8jpoG.js +9 -0
  5. package/dist/client/assets/drawioEditor-CXaTM9Ds.js +2 -0
  6. package/dist/client/assets/editor-session-GTwZS6Df.js +16 -0
  7. package/dist/client/assets/embedded-editor-base-CiNEc8vy.css +1 -0
  8. package/dist/client/assets/{en-GADW4C6J-DQ6zyCEM.js → en-7NFCYGKX-BrgYZAEe.js} +1 -1
  9. package/dist/client/assets/{excalidraw-editor-BHu6k8d4.js → excalidraw-editor-BZVM81BO.js} +2 -2
  10. package/dist/client/assets/excalidraw-editor-CyGwOhLz.css +1 -0
  11. package/dist/client/assets/excalidraw-scene-C8n9CHsK.js +176 -0
  12. package/dist/client/assets/excalidrawEditor-DxJanCze.js +2 -0
  13. package/dist/client/assets/export-page-CUfws2T-.js +39 -0
  14. package/dist/client/assets/exportDocument-BCuxqXDY.js +2 -0
  15. package/dist/client/assets/exportDocument-DV-OX74E.css +1 -0
  16. package/dist/client/assets/image-QXRYHUNP-lfTHE3bb.js +1 -0
  17. package/dist/client/assets/index-C3YnUgMD.css +1 -0
  18. package/dist/client/assets/index-D2wXraJ1.js +2 -0
  19. package/dist/client/assets/main-DtiGz7uI.js +795 -0
  20. package/dist/client/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  21. package/dist/client/assets/preview-diagram-utils-txBCoW8d.js +1 -0
  22. package/dist/client/assets/quick-switcher-controller-shW_6vPY.js +12 -0
  23. package/dist/client/assets/room-CQLoqtMo.js +1 -0
  24. package/dist/client/assets/runtime-config-DSGmGE9V.js +1 -0
  25. package/dist/client/assets/vault-api-client-C6bcGwy-.js +5 -0
  26. package/dist/client/assets/{yjs-provider-reset-guard-Cox4nxwu.js → yjs-provider-reset-guard-DE0vINDP.js} +1 -1
  27. package/dist/client/drawio-editor.html +3 -2
  28. package/dist/client/excalidraw-editor.html +3 -2
  29. package/dist/client/export-document.html +4 -3
  30. package/dist/client/index.html +97 -27
  31. package/package.json +10 -12
  32. package/src/client/app/drawio-editor-entry.js +0 -3
  33. package/src/client/app/drawio-editor.html +1 -0
  34. package/src/client/app/excalidraw-editor-entry.js +0 -3
  35. package/src/client/app/excalidraw-editor.html +1 -0
  36. package/src/client/app/export-document-entry.js +0 -3
  37. package/src/client/app/export-document.html +1 -0
  38. package/src/client/app/index.html +94 -24
  39. package/src/client/app/main-entry.js +0 -5
  40. package/src/client/application/app-shell/chat-feature.js +180 -124
  41. package/src/client/application/app-shell/comments-feature.js +1 -1
  42. package/src/client/application/app-shell/git-feature.js +4 -9
  43. package/src/client/application/app-shell/presence-feature.js +16 -10
  44. package/src/client/application/app-shell/ui-feature-shell.js +33 -15
  45. package/src/client/application/app-shell/ui-feature-sidebar.js +74 -1
  46. package/src/client/application/app-shell/ui-feature-tab-activity.js +2 -2
  47. package/src/client/application/app-shell/workspace-feature.js +22 -83
  48. package/src/client/application/app-shell-elements.js +7 -1
  49. package/src/client/application/diagram-chrome.js +161 -166
  50. package/src/client/application/diagram-preview-export.js +7 -34
  51. package/src/client/application/diagram-preview-hydrator.js +1 -19
  52. package/src/client/application/markdown-frontmatter.js +0 -2
  53. package/src/client/application/mermaid-preview-hydrator.js +1 -14
  54. package/src/client/application/plantuml-preview-hydrator.js +0 -10
  55. package/src/client/application/preview-diagram-utils.js +31 -2
  56. package/src/client/application/preview-render-compiler.js +2 -1
  57. package/src/client/application/preview-renderer.js +2 -2
  58. package/src/client/application/quick-switcher-loader.js +3 -1
  59. package/src/client/application/workspace-preview-controller.js +0 -3
  60. package/src/client/bootstrap/collabmd-app-shell.js +125 -39
  61. package/src/client/domain/excalidraw-collaboration.js +0 -2
  62. package/src/client/domain/room.js +38 -0
  63. package/src/client/export/export-pipeline.js +6 -34
  64. package/src/client/infrastructure/auth-client.js +4 -10
  65. package/src/client/infrastructure/browser-notification-port.js +33 -20
  66. package/src/client/infrastructure/browser-preferences-port.js +36 -9
  67. package/src/client/infrastructure/editor-view-adapter.js +87 -3
  68. package/src/client/infrastructure/lobby-presence.js +1 -0
  69. package/src/client/infrastructure/vault-api-client.js +25 -0
  70. package/src/client/infrastructure/workspace-sync-client.js +13 -1
  71. package/src/client/presentation/comment-overview-controller.js +18 -53
  72. package/src/client/presentation/comment-ui/comment-ui-card.js +1 -12
  73. package/src/client/presentation/comment-ui/comment-ui-layout.js +242 -219
  74. package/src/client/presentation/comment-ui/comment-ui-render.js +32 -45
  75. package/src/client/presentation/comment-ui/comment-ui-shared.js +65 -14
  76. package/src/client/presentation/comment-ui-controller.js +5 -1
  77. package/src/client/presentation/file-action-controller.js +32 -0
  78. package/src/client/presentation/file-explorer-controller.js +33 -3
  79. package/src/client/presentation/file-explorer-view.js +49 -5
  80. package/src/client/presentation/file-tree-state.js +10 -4
  81. package/src/client/presentation/quick-switcher-controller.js +243 -72
  82. package/src/client/presentation/quick-switcher-text-search.js +2 -2
  83. package/src/client/presentation/toast-controller.js +3 -0
  84. package/src/client/styles/components/feedback.css +6 -0
  85. package/src/client/styles/features/collaboration-chat.css +49 -53
  86. package/src/client/styles/features/collaboration-presence.css +9 -7
  87. package/src/client/styles/features/comment-card.css +25 -11
  88. package/src/client/styles/features/comment-overlays.css +0 -4
  89. package/src/client/styles/features/comment-overview.css +44 -21
  90. package/src/client/styles/features/comments-drawer.css +2 -100
  91. package/src/client/styles/features/diagram-preview.css +5 -18
  92. package/src/client/styles/features/embedded-preview.css +8 -33
  93. package/src/client/styles/features/quick-switcher.css +8 -0
  94. package/src/client/styles/foundation/tokens.css +1 -0
  95. package/src/client/styles/layout/editor-page.css +40 -0
  96. package/src/client/styles/layout/responsive.css +31 -1
  97. package/src/client/styles/layout/shell.css +25 -6
  98. package/src/client/styles/layout/sidebar.css +47 -15
  99. package/src/client/styles/overrides/codemirror.css +217 -15
  100. package/src/server/application/workspace-reconciliation.js +1 -1
  101. package/src/server/create-app-server.js +2 -3
  102. package/src/server/domain/bases/base-query-service.js +1 -77
  103. package/src/server/domain/comment-overview.js +10 -3
  104. package/src/server/domain/docx-exporter.js +24 -33
  105. package/src/server/domain/ripgrep-search-service.js +3 -1
  106. package/src/server/domain/workspace-state.js +32 -11
  107. package/src/server/infrastructure/git/diff-service.js +24 -13
  108. package/src/server/infrastructure/git/git-service.js +40 -10
  109. package/src/server/infrastructure/git/history-service.js +42 -29
  110. package/src/server/infrastructure/git/path-utils.js +1 -1
  111. package/src/server/infrastructure/git/responses.js +0 -95
  112. package/src/server/infrastructure/github/github-app-client.js +22 -15
  113. package/src/server/infrastructure/http/create-request-handler.js +2 -8
  114. package/src/server/infrastructure/http/create-vault-api-command-handler.js +5 -5
  115. package/src/server/infrastructure/http/http-errors.js +7 -1
  116. package/dist/client/assets/bases-preview-controller-2pLHk0uk.js +0 -143
  117. package/dist/client/assets/button-BCEiw4Rw.js +0 -1
  118. package/dist/client/assets/diagram-action-icons-aa_KOv9h.js +0 -36
  119. package/dist/client/assets/drawio-editor-DDvFoarP.js +0 -9
  120. package/dist/client/assets/drawio-embed-controller-BD1GTRwn.js +0 -1
  121. package/dist/client/assets/drawioEditor-Bu5grlH5.js +0 -2
  122. package/dist/client/assets/editor-session-DGlpiAsK.js +0 -16
  123. package/dist/client/assets/embedded-editor-base-C9roKYpW.css +0 -1
  124. package/dist/client/assets/excalidraw-editor-Cr_184EF.css +0 -1
  125. package/dist/client/assets/excalidraw-embed-controller-y2fenN_g.js +0 -1
  126. package/dist/client/assets/excalidraw-embed-reconciler-DGiagExg.js +0 -1
  127. package/dist/client/assets/excalidraw-scene-BvsQ6mLP.js +0 -176
  128. package/dist/client/assets/excalidrawEditor-4vFE--Uw.js +0 -2
  129. package/dist/client/assets/export-page-Cby8JzPO.js +0 -39
  130. package/dist/client/assets/exportDocument-C9v-bc7I.css +0 -1
  131. package/dist/client/assets/exportDocument-CRHCfsdo.js +0 -2
  132. package/dist/client/assets/file-history-view-controller-BDjEV9xJ.js +0 -112
  133. package/dist/client/assets/git-diff-view-controller-DgdM1aqb.js +0 -150
  134. package/dist/client/assets/git-panel-controller-BDTR2HVp.js +0 -207
  135. package/dist/client/assets/image-XGD7JQR6-XDUWHRpO.js +0 -1
  136. package/dist/client/assets/index-7xOyXDhP.css +0 -1
  137. package/dist/client/assets/index-Bz1KuQ6y.js +0 -2
  138. package/dist/client/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  139. package/dist/client/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  140. package/dist/client/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  141. package/dist/client/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  142. package/dist/client/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  143. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  144. package/dist/client/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  145. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  146. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  147. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  148. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  149. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  150. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  151. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  152. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  153. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  154. package/dist/client/assets/main-Ci07m9oP.js +0 -152
  155. package/dist/client/assets/preview-diagram-utils-n_bbJA9V.js +0 -1
  156. package/dist/client/assets/quick-switcher-controller-CoiYvllm.js +0 -12
  157. package/dist/client/assets/room-CDrmIdDt.js +0 -1
  158. package/dist/client/assets/runtime-config-CDgeuPWN.js +0 -1
  159. package/dist/client/assets/runtime-config-loader-BHGniHP5.js +0 -1
  160. package/dist/client/assets/runtime-paths-DvpihCEq.js +0 -1
  161. package/dist/client/assets/segmented-control-DJrHSz8M.js +0 -1
  162. package/dist/client/assets/vault-api-client-sLB2bo56.js +0 -1
  163. package/dist/client/assets/vault-paths-CVkuh0j-.js +0 -5
  164. package/src/client/bootstrap/lazy-controller-feature.js +0 -433
  165. package/src/client/infrastructure/backlinks-api-client.js +0 -16
  166. package/src/client/infrastructure/plantuml-api-client.js +0 -19
  167. package/src/client/infrastructure/runtime-config-loader.js +0 -56
  168. package/src/server/infrastructure/git/command-runner.js +0 -49
  169. package/src/server/infrastructure/git/errors.js +0 -8
  170. package/src/server/infrastructure/http/create-esm-proxy-handler.js +0 -130
@@ -1,4 +1,8 @@
1
- import { createRenderedCommentBody, formatAnchorLabel, getLatestGroupMessage } from './comment-ui-shared.js';
1
+ import {
2
+ createCommentOverviewThread,
3
+ formatAnchorLabel,
4
+ getLatestGroupMessage,
5
+ } from './comment-ui-shared.js';
2
6
 
3
7
  /**
4
8
  * @typedef {object} CommentUiRenderContext
@@ -61,18 +65,38 @@ function renderDrawer() {
61
65
  }
62
66
 
63
67
  this.commentsDrawer.classList.toggle('hidden', !this.supported || !this.drawerOpen);
68
+ if (!this.supported || !this.drawerOpen) {
69
+ return;
70
+ }
71
+
64
72
  this.commentsDrawerList.replaceChildren();
65
73
  const groups = this.getThreadGroups();
66
74
  this.commentsDrawerEmpty?.classList.toggle('hidden', groups.length > 0);
67
- if (!this.supported || groups.length === 0) {
75
+ if (groups.length === 0) {
68
76
  return;
69
77
  }
70
78
 
71
79
  const fragment = document.createDocumentFragment();
72
80
  groups.forEach((group) => {
73
- const button = document.createElement('button');
74
- button.type = 'button';
75
- button.className = 'ui-record-surface comments-drawer-item';
81
+ const latestMessage = getLatestGroupMessage(group);
82
+ const messageCount = group.threads.reduce(
83
+ (count, thread) => count + (Array.isArray(thread.messages) ? thread.messages.length : 0),
84
+ 0,
85
+ );
86
+ const button = createCommentOverviewThread({
87
+ authorName: latestMessage?.userName || group.threads[0]?.createdByName || 'Anonymous',
88
+ buttonClassName: 'comment-overview-thread comments-drawer-item',
89
+ footerClassName: 'comment-overview-thread-footer comments-drawer-item-footer',
90
+ headerClassName: 'comment-overview-thread-header comments-drawer-item-header',
91
+ lineClassName: 'comment-overview-thread-line comments-drawer-item-title',
92
+ lineLabel: formatAnchorLabel(group.anchor),
93
+ messageCount,
94
+ previewBody: latestMessage?.body || '',
95
+ previewClassName: 'comment-markdown comment-overview-thread-preview comments-drawer-item-preview',
96
+ quote: group.anchor?.quote || group.anchor?.excerpt || 'Source anchored comment',
97
+ quoteClassName: 'comment-overview-thread-quote comments-drawer-item-quote',
98
+ timestamp: latestMessage ? this.formatTimestamp(latestMessage.createdAt) : '',
99
+ });
76
100
  button.classList.toggle('is-active', this.activeCard?.groupKey === group.key);
77
101
  button.addEventListener('pointerdown', (event) => {
78
102
  event.preventDefault();
@@ -81,48 +105,11 @@ function renderDrawer() {
81
105
  this.onNavigateToLine?.(group.anchor?.startLine ?? 1);
82
106
  this.openThreadGroup(group, {
83
107
  anchor: group.anchor,
84
- origin: 'drawer',
85
- sourceRect: button.getBoundingClientRect(),
108
+ origin: 'editor',
109
+ sourceRect: this.session?.getCommentAnchorClientRect?.(group.anchor)
110
+ ?? button.getBoundingClientRect(),
86
111
  });
87
112
  });
88
-
89
- const header = document.createElement('div');
90
- header.className = 'ui-record-header comments-drawer-item-header';
91
-
92
- const title = document.createElement('span');
93
- title.className = 'comments-drawer-item-title';
94
- title.textContent = formatAnchorLabel(group.anchor);
95
-
96
- const count = document.createElement('span');
97
- count.className = 'ui-pill-badge ui-pill-badge--count ui-pill-badge--accent comments-drawer-item-count';
98
- count.textContent = String(group.threads.length);
99
-
100
- header.append(title, count);
101
-
102
- const quote = document.createElement('p');
103
- quote.className = 'comments-drawer-item-quote';
104
- quote.textContent = group.anchor.quote || group.anchor.excerpt || 'Source anchored comment';
105
-
106
- const latestMessage = getLatestGroupMessage(group);
107
- const preview = createRenderedCommentBody(
108
- latestMessage?.body || '',
109
- 'comment-markdown comments-drawer-item-preview',
110
- );
111
-
112
- const footer = document.createElement('div');
113
- footer.className = 'ui-record-meta comments-drawer-item-footer';
114
- const countLabel = document.createElement('span');
115
- countLabel.textContent = `${group.threads.length} thread${group.threads.length === 1 ? '' : 's'}`;
116
-
117
- const updatedLabel = document.createElement('span');
118
- updatedLabel.className = 'comments-drawer-item-updated';
119
- updatedLabel.textContent = latestMessage
120
- ? `${latestMessage.userName} • ${this.formatTimestamp(latestMessage.createdAt)}`
121
- : '';
122
-
123
- footer.append(countLabel, updatedLabel);
124
-
125
- button.append(header, quote, preview, footer);
126
113
  fragment.appendChild(button);
127
114
  });
128
115
 
@@ -13,10 +13,6 @@ export const COMMENT_CARD_WIDTH = 520;
13
13
  export const COMMENT_SELECTION_REVEAL_DELAY_MS = 150;
14
14
  export const COMMENT_SELECTION_CHIP_GAP = 12;
15
15
  export const COMMENT_CONTROL_SLOT_HEIGHT = 36;
16
- export const COMMENT_PREVIEW_BADGE_MIN_WIDTH = 28;
17
- export const COMMENT_PREVIEW_RAIL_SLOT_HEIGHT = 30;
18
- export const COMMENT_PREVIEW_RAIL_MIN_WIDTH = 400;
19
- export const COMMENT_PREVIEW_RAIL_BREAKPOINT = 769;
20
16
  export const COMMENT_REACTION_PRESET_EMOJIS = Object.freeze(['👍', '❤️', '🎉', '👀', '🚀']);
21
17
  export const COMMENT_REACTION_MORE_EMOJIS = Object.freeze(['😂', '🔥', '✅', '🙏', '💡', '🤔', '👏', '😄', '🎯', '🙌']);
22
18
 
@@ -55,19 +51,15 @@ export function areAnchorsEqual(left, right) {
55
51
  }
56
52
 
57
53
  export function formatAnchorLabel(anchor) {
58
- if (!anchor) {
54
+ const startLine = Number(anchor?.startLine || 0);
55
+ const endLine = Number(anchor?.endLine || startLine);
56
+ if (!Number.isFinite(startLine) || startLine <= 0) {
59
57
  return 'No source anchor';
60
58
  }
61
59
 
62
- if ((anchor.kind || anchor.anchorKind) === 'text' && anchor.quote) {
63
- return anchor.startLine === anchor.endLine
64
- ? `Line ${anchor.startLine}`
65
- : `Lines ${anchor.startLine}-${anchor.endLine}`;
66
- }
67
-
68
- return anchor.startLine === anchor.endLine
69
- ? `Line ${anchor.startLine}`
70
- : `Lines ${anchor.startLine}-${anchor.endLine}`;
60
+ return startLine === endLine
61
+ ? `Line ${startLine}`
62
+ : `Lines ${startLine}-${endLine}`;
71
63
  }
72
64
 
73
65
  export function getAnchorGroupKey(anchor = {}) {
@@ -120,6 +112,65 @@ export function createRenderedCommentBody(body, className = 'comment-markdown')
120
112
  return container;
121
113
  }
122
114
 
115
+ export function createCommentOverviewThread({
116
+ authorName = 'Anonymous',
117
+ buttonClassName = 'comment-overview-thread',
118
+ footerClassName = 'comment-overview-thread-footer',
119
+ headerClassName = 'comment-overview-thread-header',
120
+ lineClassName = 'comment-overview-thread-line',
121
+ lineLabel = '',
122
+ messageCount = 0,
123
+ previewBody = '',
124
+ previewClassName = 'comment-markdown comment-overview-thread-preview',
125
+ quote = 'Source anchored comment',
126
+ quoteClassName = 'comment-overview-thread-quote',
127
+ timestamp = '',
128
+ } = {}) {
129
+ const button = document.createElement('button');
130
+ button.type = 'button';
131
+ button.className = buttonClassName;
132
+
133
+ const header = document.createElement('div');
134
+ header.className = headerClassName;
135
+
136
+ const line = document.createElement('span');
137
+ line.className = lineClassName;
138
+ line.textContent = lineLabel;
139
+
140
+ const meta = document.createElement('span');
141
+ meta.className = 'comment-overview-thread-meta';
142
+ meta.textContent = timestamp;
143
+ header.append(line, meta);
144
+
145
+ const body = document.createElement('div');
146
+ body.className = 'comment-overview-thread-body';
147
+ const preview = createRenderedCommentBody(previewBody, previewClassName);
148
+
149
+ const quoteSection = document.createElement('div');
150
+ quoteSection.className = 'comment-overview-thread-section comment-overview-thread-reference';
151
+ const quoteLabel = document.createElement('span');
152
+ quoteLabel.className = 'comment-overview-thread-section-label';
153
+ quoteLabel.textContent = 'Reference';
154
+ const quoteNode = document.createElement('span');
155
+ quoteNode.className = quoteClassName;
156
+ quoteNode.textContent = quote;
157
+ quoteSection.append(quoteLabel, quoteNode);
158
+ body.append(preview, quoteSection);
159
+
160
+ const footer = document.createElement('div');
161
+ footer.className = footerClassName;
162
+ const author = document.createElement('span');
163
+ author.className = 'comment-overview-thread-author';
164
+ author.textContent = authorName;
165
+ const messages = document.createElement('span');
166
+ messages.className = 'comment-overview-thread-message-count';
167
+ messages.textContent = `${messageCount} message${messageCount === 1 ? '' : 's'}`;
168
+ footer.append(author, messages);
169
+
170
+ button.append(header, body, footer);
171
+ return button;
172
+ }
173
+
123
174
  export function hasLocalReaction(reaction, localUserId) {
124
175
  return Boolean(localUserId && reaction?.users?.some((user) => user?.userId === localUserId));
125
176
  }
@@ -89,9 +89,13 @@ export class CommentUiController {
89
89
  if (this.activeCard?.mode === 'create' && this.activeCard.origin === 'preview') {
90
90
  return;
91
91
  }
92
- this.previewSelection = this.supported && this.fileKind === 'markdown'
92
+ const previewSelection = this.supported && this.fileKind === 'markdown'
93
93
  ? createPreviewSelection(window.getSelection(), this.previewElement, this.session?.getText?.() ?? '')
94
94
  : null;
95
+ if (!previewSelection && !this.previewSelection) {
96
+ return;
97
+ }
98
+ this.previewSelection = previewSelection;
95
99
  this.scheduleLayoutRefresh();
96
100
  };
97
101
  this.handleCommentSelectionButtonPointerDown = (event) => {
@@ -57,7 +57,9 @@ export class FileActionController {
57
57
  : { matches: false },
58
58
  onFileDelete,
59
59
  onFileSelect,
60
+ onShowFileExtensionsChange,
60
61
  pendingWorkspaceRequestIds = null,
62
+ showFileExtensions = false,
61
63
  state,
62
64
  toastController,
63
65
  vaultClient,
@@ -66,13 +68,16 @@ export class FileActionController {
66
68
  }) {
67
69
  this.onFileDelete = onFileDelete;
68
70
  this.onFileSelect = onFileSelect;
71
+ this.onShowFileExtensionsChange = onShowFileExtensionsChange;
69
72
  this.pendingWorkspaceRequestIds = pendingWorkspaceRequestIds;
73
+ this.showFileExtensions = Boolean(showFileExtensions);
70
74
  this.state = state;
71
75
  this.toastController = toastController;
72
76
  this.vaultClient = vaultClient;
73
77
  this.view = view;
74
78
  this.refresh = refresh;
75
79
  this.createButton = document.getElementById('sidebarCreateBtn');
80
+ this.fileExplorerOptionsButton = document.getElementById('fileExplorerOptionsBtn');
76
81
  this.actionDialog = document.getElementById('fileActionDialog');
77
82
  this.actionForm = document.getElementById('fileActionForm');
78
83
  this.actionTitle = document.getElementById('fileActionTitle');
@@ -87,6 +92,9 @@ export class FileActionController {
87
92
  this.createMenu = new CreateMenuPresenter({
88
93
  mobileBreakpointQuery,
89
94
  });
95
+ this.fileExplorerOptionsMenu = new CreateMenuPresenter({
96
+ mobileBreakpointQuery,
97
+ });
90
98
  this.pendingAction = null;
91
99
  this.actionBusy = false;
92
100
  }
@@ -95,6 +103,9 @@ export class FileActionController {
95
103
  this.createButton?.addEventListener('click', () => {
96
104
  this.openRootCreateMenu({ anchor: this.createButton });
97
105
  });
106
+ this.fileExplorerOptionsButton?.addEventListener('click', () => {
107
+ this.openFileExplorerOptionsMenu({ anchor: this.fileExplorerOptionsButton });
108
+ });
98
109
  this.actionCancelButton?.addEventListener('click', () => this.closeActionDialog());
99
110
  this.actionForm?.addEventListener('submit', (event) => {
100
111
  event.preventDefault();
@@ -313,6 +324,7 @@ export class FileActionController {
313
324
 
314
325
  this.pendingAction = { requiresInput, emptyMessage, onSubmit };
315
326
  this.createMenu.close({ restoreFocus: false });
327
+ this.fileExplorerOptionsMenu.close({ restoreFocus: false });
316
328
  this.view?.removeContextMenu?.();
317
329
 
318
330
  this.actionTitle.textContent = title;
@@ -716,6 +728,7 @@ export class FileActionController {
716
728
 
717
729
  openCreateMenu({ anchor = this.createButton, parentDir = '' } = {}) {
718
730
  this.view?.removeContextMenu?.();
731
+ this.fileExplorerOptionsMenu.close({ restoreFocus: false });
719
732
  this.createMenu.toggle({
720
733
  anchor,
721
734
  items: this.getCreateActions({ parentDir }),
@@ -723,6 +736,25 @@ export class FileActionController {
723
736
  });
724
737
  }
725
738
 
739
+ openFileExplorerOptionsMenu({ anchor = this.fileExplorerOptionsButton } = {}) {
740
+ this.view?.removeContextMenu?.();
741
+ this.createMenu.close({ restoreFocus: false });
742
+ this.fileExplorerOptionsMenu.toggle({
743
+ anchor,
744
+ items: [{
745
+ icon: '<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="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>',
746
+ id: 'show-file-extensions',
747
+ label: 'Show file extensions',
748
+ meta: this.showFileExtensions ? 'On' : 'Off',
749
+ onSelect: () => {
750
+ this.showFileExtensions = !this.showFileExtensions;
751
+ this.onShowFileExtensionsChange?.(this.showFileExtensions);
752
+ },
753
+ }],
754
+ title: 'File explorer options',
755
+ });
756
+ }
757
+
726
758
  handleNewFile({ parentDir = '' } = {}) {
727
759
  const context = this.getCreateContext(parentDir);
728
760
 
@@ -9,14 +9,18 @@ export class FileExplorerController {
9
9
  onFileSelect,
10
10
  onFileDelete,
11
11
  pendingWorkspaceRequestIds = null,
12
+ onShowFileExtensionsChange,
13
+ showFileExtensions = false,
12
14
  toastController,
13
15
  vaultClient,
14
16
  }) {
15
17
  this.onFileSelect = onFileSelect;
16
18
  this.onFileDelete = onFileDelete;
19
+ this.onShowFileExtensionsChange = onShowFileExtensionsChange;
17
20
  this.toastController = toastController;
18
21
  this.vaultClient = vaultClient;
19
22
  this.state = new FileTreeState();
23
+ this.showFileExtensions = Boolean(showFileExtensions);
20
24
  this.threadCounts = new Map();
21
25
  this.view = new FileExplorerView({
22
26
  mobileBreakpointQuery,
@@ -27,7 +31,12 @@ export class FileExplorerController {
27
31
  },
28
32
  onDirectoryToggle: (pathValue) => {
29
33
  this.state.toggleDirectory(pathValue);
30
- this.renderTree();
34
+ if (!this.view.rerenderDirectoryBranch(pathValue, this.state.tree, {
35
+ activeFilePath: this.state.activeFilePath,
36
+ expandedDirs: this.state.expandedDirs,
37
+ })) {
38
+ this.renderTree();
39
+ }
31
40
  },
32
41
  onEntryDrop: (payload) => this.actionController.moveEntryByDrop(payload),
33
42
  onFileContextMenu: (event, payload) => {
@@ -54,7 +63,13 @@ export class FileExplorerController {
54
63
  mobileBreakpointQuery,
55
64
  onFileDelete: this.onFileDelete,
56
65
  onFileSelect: this.onFileSelect,
66
+ onShowFileExtensionsChange: (enabled) => {
67
+ this.showFileExtensions = Boolean(enabled);
68
+ this.onShowFileExtensionsChange?.(this.showFileExtensions);
69
+ this.renderTree();
70
+ },
57
71
  pendingWorkspaceRequestIds,
72
+ showFileExtensions: this.showFileExtensions,
58
73
  refresh: () => this.refresh(),
59
74
  state: this.state,
60
75
  toastController: this.toastController,
@@ -86,15 +101,25 @@ export class FileExplorerController {
86
101
  }
87
102
 
88
103
  setActiveFile(filePath) {
104
+ const expandedDirectoryCount = this.state.expandedDirs.size;
89
105
  this.state.setActiveFile(filePath);
90
- this.renderTree();
106
+ if (this.state.searchQuery) {
107
+ this.view.setActiveFile(filePath);
108
+ return;
109
+ }
110
+ if (
111
+ this.state.expandedDirs.size !== expandedDirectoryCount
112
+ || !this.view.setActiveFile(filePath)
113
+ ) {
114
+ this.renderTree();
115
+ }
91
116
  }
92
117
 
93
118
  setThreadCounts(threadCounts = new Map()) {
94
119
  this.threadCounts = threadCounts instanceof Map
95
120
  ? new Map(threadCounts)
96
121
  : new Map(Object.entries(threadCounts ?? {}));
97
- this.renderTree();
122
+ this.view.setThreadCounts(this.threadCounts);
98
123
  }
99
124
 
100
125
  revealFile(filePath, { clearSearch = false } = {}) {
@@ -111,6 +136,10 @@ export class FileExplorerController {
111
136
  return this.state.flatFiles;
112
137
  }
113
138
 
139
+ get fileEntries() {
140
+ return this.state.flatFileEntries;
141
+ }
142
+
114
143
  get flatDocumentFiles() {
115
144
  return this.state.flatFiles.filter((path) => !isImageAttachmentFilePath(path));
116
145
  }
@@ -126,6 +155,7 @@ export class FileExplorerController {
126
155
  reset,
127
156
  searchMatches: this.state.getSearchMatches(),
128
157
  searchQuery: this.state.searchQuery,
158
+ showFileExtensions: this.showFileExtensions,
129
159
  threadCounts: this.threadCounts,
130
160
  tree: this.state.tree,
131
161
  });
@@ -52,6 +52,7 @@ export class FileExplorerView {
52
52
  this.searchInput = document.getElementById('fileSearchInput');
53
53
  this.renderedDirectoryWrappers = new Map();
54
54
  this.renderedChildContainers = new Map();
55
+ this.renderedFileItems = new Map();
55
56
  this.lastRenderMode = 'tree';
56
57
  this.longPressTimer = 0;
57
58
  this.longPressContext = null;
@@ -66,6 +67,7 @@ export class FileExplorerView {
66
67
  this.autoExpandTargetPath = '';
67
68
  this.rootDropZone = null;
68
69
  this.threadCounts = new Map();
70
+ this.showFileExtensions = false;
69
71
  }
70
72
 
71
73
  initialize() {
@@ -120,17 +122,51 @@ export class FileExplorerView {
120
122
  }
121
123
 
122
124
  revealFile(filePath) {
123
- const fileItem = Array.from(this.treeContainer?.querySelectorAll('.file-tree-file') ?? [])
124
- .find((element) => element.dataset.path === filePath);
125
- fileItem?.scrollIntoView({ block: 'nearest' });
125
+ this.renderedFileItems.get(filePath)?.scrollIntoView({ block: 'nearest' });
126
126
  }
127
127
 
128
- render({ activeFilePath, changedPaths = null, expandedDirs, reset = false, searchMatches, searchQuery, threadCounts = new Map(), tree }) {
128
+ setActiveFile(filePath) {
129
+ this.treeContainer?.querySelector('.file-tree-file.active')?.classList.remove('active');
130
+ if (!filePath) {
131
+ return true;
132
+ }
133
+
134
+ const fileItem = this.renderedFileItems.get(filePath);
135
+ fileItem?.classList.add('active');
136
+ return Boolean(fileItem);
137
+ }
138
+
139
+ setThreadCounts(threadCounts) {
140
+ this.threadCounts = threadCounts instanceof Map ? threadCounts : new Map();
141
+ this.renderedFileItems.forEach((button, filePath) => {
142
+ const threadCount = Number(this.threadCounts.get(filePath) ?? 0) || 0;
143
+ let badge = button.querySelector('.file-tree-comment-count');
144
+ button.classList.toggle('has-comments', threadCount > 0);
145
+
146
+ if (threadCount <= 0) {
147
+ delete button.dataset.threadCount;
148
+ badge?.remove();
149
+ return;
150
+ }
151
+
152
+ button.dataset.threadCount = String(threadCount);
153
+ if (!badge) {
154
+ badge = document.createElement('span');
155
+ badge.className = 'file-tree-comment-count';
156
+ button.appendChild(badge);
157
+ }
158
+ badge.textContent = String(threadCount);
159
+ badge.setAttribute('aria-label', `${threadCount} open comment thread${threadCount === 1 ? '' : 's'}`);
160
+ });
161
+ }
162
+
163
+ render({ activeFilePath, changedPaths = null, expandedDirs, reset = false, searchMatches, searchQuery, showFileExtensions = false, threadCounts = new Map(), tree }) {
129
164
  if (!this.treeContainer) {
130
165
  return;
131
166
  }
132
167
 
133
168
  this.threadCounts = threadCounts instanceof Map ? threadCounts : new Map();
169
+ this.showFileExtensions = Boolean(showFileExtensions);
134
170
  this.currentSearchQuery = String(searchQuery ?? '');
135
171
  if (this.currentSearchQuery) {
136
172
  this.clearDragFeedback();
@@ -248,6 +284,7 @@ export class FileExplorerView {
248
284
  resetTreeIndexes() {
249
285
  this.renderedDirectoryWrappers.clear();
250
286
  this.renderedChildContainers.clear();
287
+ this.renderedFileItems.clear();
251
288
  }
252
289
 
253
290
  renderNodes(nodes, container, { activeFilePath, depth, expandedDirs }) {
@@ -408,6 +445,11 @@ export class FileExplorerView {
408
445
  this.renderedChildContainers.delete(pathValue);
409
446
  }
410
447
  });
448
+ Array.from(this.renderedFileItems.keys()).forEach((pathValue) => {
449
+ if (pathValue.startsWith(prefix)) {
450
+ this.renderedFileItems.delete(pathValue);
451
+ }
452
+ });
411
453
  }
412
454
 
413
455
  createFileItem({ activeFilePath, depth, filePath, fileType = 'file', name }) {
@@ -453,9 +495,10 @@ export class FileExplorerView {
453
495
  if (threadCount > 0) {
454
496
  button.dataset.threadCount = String(threadCount);
455
497
  }
498
+ const displayName = this.showFileExtensions ? String(name ?? '') : stripVaultFileExtension(name);
456
499
  button.innerHTML = `
457
500
  ${this.getFileIconSvg({ isBase, isDrawio, isExcalidraw, isImage, isMermaid, isPlantUml })}
458
- <span class="file-tree-name">${escapeHtml(stripVaultFileExtension(name))}</span>
501
+ <span class="file-tree-name">${escapeHtml(displayName)}</span>
459
502
  ${threadCount > 0 ? `<span class="file-tree-comment-count" aria-label="${threadCount} open comment thread${threadCount === 1 ? '' : 's'}">${threadCount}</span>` : ''}
460
503
  `;
461
504
  this.configureDragSource(button, { path: filePath, type: 'file' });
@@ -474,6 +517,7 @@ export class FileExplorerView {
474
517
  this.onFileContextMenu?.(this.createLongPressEvent(button), { filePath, type: 'file' });
475
518
  });
476
519
 
520
+ this.renderedFileItems.set(filePath, button);
477
521
  return button;
478
522
  }
479
523
 
@@ -1,6 +1,6 @@
1
1
  import { normalizeVaultPathInput } from '../domain/vault-paths.js';
2
2
 
3
- function flattenTree(nodes, files = [], searchEntries = []) {
3
+ function flattenTree(nodes, files = [], searchEntries = [], fileEntries = []) {
4
4
  for (const node of nodes) {
5
5
  if (!node?.path || !node?.type) {
6
6
  continue;
@@ -23,15 +23,19 @@ function flattenTree(nodes, files = [], searchEntries = []) {
23
23
  || node.type === 'image'
24
24
  ) {
25
25
  files.push(node.path);
26
+ fileEntries.push({
27
+ mtimeMs: node.mtimeMs,
28
+ path: node.path,
29
+ });
26
30
  continue;
27
31
  }
28
32
 
29
33
  if (node.type === 'directory' && Array.isArray(node.children)) {
30
- flattenTree(node.children, files, searchEntries);
34
+ flattenTree(node.children, files, searchEntries, fileEntries);
31
35
  }
32
36
  }
33
37
 
34
- return { files, searchEntries };
38
+ return { fileEntries, files, searchEntries };
35
39
  }
36
40
 
37
41
  function findNodeByPath(nodes, pathValue) {
@@ -82,6 +86,7 @@ export class FileTreeState {
82
86
  constructor() {
83
87
  this.tree = [];
84
88
  this.flatFiles = [];
89
+ this.flatFileEntries = [];
85
90
  this.flatSearchEntries = [];
86
91
  this.activeFilePath = null;
87
92
  this.expandedDirs = new Set();
@@ -90,8 +95,9 @@ export class FileTreeState {
90
95
 
91
96
  setTree(tree) {
92
97
  this.tree = Array.isArray(tree) ? tree : [];
93
- const flattened = flattenTree(this.tree, [], []);
98
+ const flattened = flattenTree(this.tree, [], [], []);
94
99
  this.flatFiles = flattened.files;
100
+ this.flatFileEntries = flattened.fileEntries;
95
101
  this.flatSearchEntries = flattened.searchEntries;
96
102
  }
97
103