@fresh-editor/fresh-editor 0.2.23 → 0.2.24

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.
@@ -37,6 +37,11 @@ function setGlobalComposeEnabled(value: boolean): void {
37
37
  interface TableWidthInfo {
38
38
  maxW: number[];
39
39
  allocated: number[];
40
+ // True iff this row is the markdown source separator (`|---|---|---|`) — the
41
+ // border code uses this to avoid drawing a duplicate `├─┼─┤` next to it.
42
+ // Optional for backwards-compat with persisted view states from older
43
+ // sessions.
44
+ isSourceSep?: boolean;
40
45
  }
41
46
 
42
47
  // Helper: check whether the active split has compose mode for this buffer
@@ -90,6 +95,161 @@ const HTML_ENTITY_MAP: Record<string, string> = {
90
95
  laquo: "\u00AB", raquo: "\u00BB", ensp: "\u2002", emsp: "\u2003", thinsp: "\u2009",
91
96
  };
92
97
 
98
+ // =============================================================================
99
+ // Table border virtual lines (top/bottom + inter-row separators)
100
+ // =============================================================================
101
+ //
102
+ // Markdown tables source-encode only an underline-style separator between the
103
+ // header and the first data row. In compose mode we already conceal the
104
+ // pipe characters into Unicode box-drawing (`│`, `├`, `┼`, `┤`). This module
105
+ // adds the *missing* visual frame: a `┌─┬─┐` top border above the header,
106
+ // `├─┼─┤` separators between consecutive data rows (so each row reads as a
107
+ // distinct cell), and a `└─┴─┘` bottom border below the last row.
108
+ //
109
+ // Implementation:
110
+ //
111
+ // * Borders are virtual lines (no source bytes), keyed per-line via a
112
+ // unique namespace `md-tb-${lineNumber}`. The namespace lets us
113
+ // clear+rebuild borders for one row without disturbing other tables.
114
+ // * "First/last/source-separator" classification is derived from the
115
+ // cached widthMap (a row is "known" iff it has a TableWidthInfo entry).
116
+ // This is cheap and stable across scrolls because widthMap accumulates.
117
+ // * Border column widths come from the same `allocated` widths used by
118
+ // processLineConceals, so the borders line up exactly with the cell
119
+ // conceals.
120
+
121
+ /** Build a horizontal table border line of the given style for a row. */
122
+ function buildTableBorderLine(
123
+ allocated: number[],
124
+ left: string,
125
+ mid: string,
126
+ right: string,
127
+ ): string {
128
+ // Each cell render is `│ <text padded to allocated[i] - 2> │` (2 chars of
129
+ // inside padding). The matching border slot must therefore be
130
+ // `allocated[i]` wide of `─` characters between the corner/junction marks.
131
+ const parts: string[] = [];
132
+ for (let i = 0; i < allocated.length; i++) {
133
+ const fill = "─".repeat(Math.max(1, allocated[i]));
134
+ parts.push(fill);
135
+ }
136
+ return left + parts.join(mid) + right;
137
+ }
138
+
139
+ /** True if `lineContent` looks like a markdown table separator row. */
140
+ function isTableSeparatorContent(lineContent: string): boolean {
141
+ return /^\|[-:\s|]+\|$/.test(lineContent.trim());
142
+ }
143
+
144
+ /** Re-emit the table border virtual lines for the given table-row group.
145
+ *
146
+ * Detects the group's first/last visible rows by consulting `widthMap`
147
+ * (which is updated by `processTableAlignment` before this runs). A row at
148
+ * `lineNumber - 1` or `lineNumber + 1` that is *not* in `widthMap` is treated
149
+ * as the boundary of the table's visible extent.
150
+ */
151
+ function processTableBorders(
152
+ bufferId: number,
153
+ lines: Array<{
154
+ line_number: number;
155
+ byte_start: number;
156
+ byte_end: number;
157
+ content: string;
158
+ }>,
159
+ widthMap: Map<number, TableWidthInfo>,
160
+ ): void {
161
+ // Use theme keys (resolved at render time so the borders follow theme
162
+ // changes — same pattern as addOverlay's fg/bg options).
163
+ //
164
+ // * fg → editor.fg (the default document foreground, matching the
165
+ // concealed `│` / `─` glyphs inside row text so the virtual
166
+ // `┌─┬─┐` / `├─┼─┤` / `└─┴─┘` frame doesn't create a visible seam
167
+ // where it meets the in-text borders)
168
+ // * bg → editor.bg (matches the document background so the borders
169
+ // blend in rather than carving an opaque slab through the page)
170
+ const borderOptions = { fg: "editor.fg", bg: "editor.bg" };
171
+
172
+ for (const line of lines) {
173
+ const ns = `md-tb-${line.line_number}`;
174
+ // Always start by clearing this row's previous borders (handles
175
+ // edits that removed/widened the row, scrolls that change the
176
+ // first/last classification, etc.).
177
+ editor.clearVirtualTextNamespace(bufferId, ns);
178
+
179
+ const trimmed = line.content.trim();
180
+ const isTableRow = trimmed.startsWith("|") || trimmed.endsWith("|");
181
+ if (!isTableRow) continue;
182
+
183
+ const widthInfo = widthMap.get(line.line_number);
184
+ if (!widthInfo || widthInfo.allocated.length === 0) continue;
185
+
186
+ const allocated = widthInfo.allocated;
187
+ // Prefer the cached flag (set by processTableAlignment from the source
188
+ // text of this exact row); fall back to a regex check in case this row
189
+ // was loaded from a persisted view state without the flag.
190
+ const isSourceSep = widthInfo.isSourceSep === true
191
+ || isTableSeparatorContent(line.content);
192
+
193
+ const prevIsTable = widthMap.has(line.line_number - 1);
194
+ const nextIsTable = widthMap.has(line.line_number + 1);
195
+
196
+ // Top border: only above the very first known row of the table.
197
+ // ┌─┬─┐ — opens the frame above the header.
198
+ if (!prevIsTable) {
199
+ editor.addVirtualLine(
200
+ bufferId,
201
+ line.byte_start,
202
+ buildTableBorderLine(allocated, "┌", "┬", "┐"),
203
+ borderOptions,
204
+ true, // above
205
+ ns,
206
+ 0,
207
+ );
208
+ }
209
+
210
+ // Inter-row separator: between consecutive *data* rows.
211
+ //
212
+ // Skip if either side is the source separator row (`|---|---|---|`)
213
+ // because the source already provides `├─┼─┤` there via conceals —
214
+ // adding another above/below would draw two adjacent separator lines.
215
+ //
216
+ // Drawn ABOVE the current row when its predecessor is also a (non-
217
+ // source-separator) table row, so each row owns the separator that
218
+ // appears above it.
219
+ const prevInfo = widthMap.get(line.line_number - 1);
220
+ const prevIsSourceSep = prevInfo?.isSourceSep === true;
221
+ if (prevIsTable && !isSourceSep && !prevIsSourceSep) {
222
+ editor.addVirtualLine(
223
+ bufferId,
224
+ line.byte_start,
225
+ buildTableBorderLine(allocated, "├", "┼", "┤"),
226
+ borderOptions,
227
+ true, // above
228
+ ns,
229
+ 1,
230
+ );
231
+ }
232
+
233
+ // Bottom border: only below the last known row of the table.
234
+ // └─┴─┘ — closes the frame. Anchor at the END of the row's bytes
235
+ // (one before the trailing newline) and place "below".
236
+ if (!nextIsTable) {
237
+ // byte_end points just past the newline; anchor at last byte of
238
+ // the row content so the virtual line renders directly under it.
239
+ const anchor = Math.max(line.byte_start, line.byte_end - 1);
240
+ editor.addVirtualLine(
241
+ bufferId,
242
+ anchor,
243
+ buildTableBorderLine(allocated, "└", "┴", "┘"),
244
+ borderOptions,
245
+ false, // below
246
+ ns,
247
+ 0,
248
+ );
249
+ }
250
+ }
251
+ }
252
+
93
253
  // =============================================================================
94
254
  // Block-based parser for hanging indent support
95
255
  // =============================================================================
@@ -1375,18 +1535,25 @@ function processTableAlignment(
1375
1535
  if (allocGrew(widthMap.get(ln)!)) { needsRefresh = true; break; }
1376
1536
  }
1377
1537
 
1378
- // Store merged widths for all lines in the group AND propagate
1379
- // back to adjacent cached lines so they pick up wider columns
1380
- // without needing to be re-delivered by lines_changed.
1381
- const info: TableWidthInfo = { maxW: merged, allocated };
1538
+ // Store merged widths for each line in the group. We tag the source
1539
+ // separator row (`|---|---|---|`) so the border renderer can skip
1540
+ // drawing a duplicate `├─┼─┤` adjacent to it (the source separator is
1541
+ // already concealed into one). Each line gets its own info object so
1542
+ // the per-row `isSourceSep` flag is independent.
1382
1543
  for (const line of group) {
1383
- widthMap.set(line.line_number, info);
1544
+ const isSep = /^\|[-:\s|]+\|$/.test(line.content.trim());
1545
+ widthMap.set(line.line_number, { maxW: merged, allocated, isSourceSep: isSep });
1384
1546
  }
1547
+ // Adjacent cached lines (already-processed neighbours of this group)
1548
+ // need their `allocated` updated but should keep their existing
1549
+ // `isSourceSep` flag — they were classified when they were processed.
1385
1550
  for (let ln = firstLine - 1; widthMap.has(ln); ln--) {
1386
- widthMap.set(ln, info);
1551
+ const prev = widthMap.get(ln)!;
1552
+ widthMap.set(ln, { maxW: merged, allocated, isSourceSep: prev.isSourceSep });
1387
1553
  }
1388
1554
  for (let ln = lastLine + 1; widthMap.has(ln); ln++) {
1389
- widthMap.set(ln, info);
1555
+ const prev = widthMap.get(ln)!;
1556
+ widthMap.set(ln, { maxW: merged, allocated, isSourceSep: prev.isSourceSep });
1390
1557
  }
1391
1558
  }
1392
1559
 
@@ -1426,6 +1593,15 @@ function onMarkdownLinesChanged(data: {
1426
1593
  processLineSoftBreaks(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number);
1427
1594
  }
1428
1595
 
1596
+ // Add/refresh table border virtual lines (top/bottom + inter-row separators).
1597
+ // Runs AFTER processTableAlignment so the widthMap reflects the latest
1598
+ // allocated widths, and AFTER processLineConceals so the borders we draw
1599
+ // line up with the cell pipes the conceals produce.
1600
+ const widthMapForBorders = getTableWidths(data.buffer_id);
1601
+ if (widthMapForBorders) {
1602
+ processTableBorders(data.buffer_id, data.lines, widthMapForBorders);
1603
+ }
1604
+
1429
1605
  if (tableWidthsGrew) {
1430
1606
  editor.refreshLines(data.buffer_id);
1431
1607
  }
@@ -36,7 +36,9 @@
36
36
  "panel.replace_all_btn": "Replace All (Alt+Ret)",
37
37
  "panel.type_pattern": "Type a search pattern above",
38
38
  "panel.matches_count": "Matches (%{count} in %{files} files)",
39
- "panel.matches_title": "Matches"
39
+ "panel.matches_title": "Matches",
40
+ "prompt.confirm_replace": "Replace %{count} match(es) in %{files} file(s)? Undo only covers files still open in this session. Press Enter to confirm, Esc to cancel.",
41
+ "status.replace_cancelled": "Replacement cancelled"
40
42
  },
41
43
  "cs": {
42
44
  "cmd.search_replace": "Hledat a nahradit v projektu",
@@ -75,7 +77,9 @@
75
77
  "panel.replace_all_btn": "Nahradit vše (Alt+Ret)",
76
78
  "panel.type_pattern": "Zadejte vyhledávací vzor",
77
79
  "panel.matches_count": "Shody (%{count} v %{files} souborech)",
78
- "panel.matches_title": "Shody"
80
+ "panel.matches_title": "Shody",
81
+ "prompt.confirm_replace": "Nahradit %{count} výskyt(ů) v %{files} souboru(ech)? Vrátit zpět lze jen soubory otevřené v této relaci. Enter potvrdí, Esc zruší.",
82
+ "status.replace_cancelled": "Nahrazení zrušeno"
79
83
  },
80
84
  "de": {
81
85
  "cmd.search_replace": "Suchen und Ersetzen im Projekt",
@@ -114,7 +118,9 @@
114
118
  "panel.replace_all_btn": "Alle ersetzen (Alt+Ret)",
115
119
  "panel.type_pattern": "Suchmuster oben eingeben",
116
120
  "panel.matches_count": "Treffer (%{count} in %{files} Dateien)",
117
- "panel.matches_title": "Treffer"
121
+ "panel.matches_title": "Treffer",
122
+ "prompt.confirm_replace": "%{count} Treffer in %{files} Datei(en) ersetzen? Rückgängig wirkt nur auf Dateien, die in dieser Sitzung geöffnet sind. Enter bestätigt, Esc bricht ab.",
123
+ "status.replace_cancelled": "Ersetzen abgebrochen"
118
124
  },
119
125
  "es": {
120
126
  "cmd.search_replace": "Buscar y Reemplazar en Proyecto",
@@ -153,7 +159,9 @@
153
159
  "panel.replace_all_btn": "Reemplazar todo (Alt+Ret)",
154
160
  "panel.type_pattern": "Escriba un patrón de búsqueda",
155
161
  "panel.matches_count": "Coincidencias (%{count} en %{files} archivos)",
156
- "panel.matches_title": "Coincidencias"
162
+ "panel.matches_title": "Coincidencias",
163
+ "prompt.confirm_replace": "¿Reemplazar %{count} coincidencia(s) en %{files} archivo(s)? Deshacer solo cubre los archivos abiertos en esta sesión. Intro confirma, Esc cancela.",
164
+ "status.replace_cancelled": "Reemplazo cancelado"
157
165
  },
158
166
  "fr": {
159
167
  "cmd.search_replace": "Rechercher et Remplacer dans le Projet",
@@ -192,7 +200,9 @@
192
200
  "panel.replace_all_btn": "Tout remplacer (Alt+Ret)",
193
201
  "panel.type_pattern": "Saisissez un motif de recherche",
194
202
  "panel.matches_count": "Correspondances (%{count} dans %{files} fichiers)",
195
- "panel.matches_title": "Correspondances"
203
+ "panel.matches_title": "Correspondances",
204
+ "prompt.confirm_replace": "Remplacer %{count} correspondance(s) dans %{files} fichier(s) ? L'annulation ne concerne que les fichiers ouverts dans cette session. Entrée confirme, Échap annule.",
205
+ "status.replace_cancelled": "Remplacement annulé"
196
206
  },
197
207
  "it": {
198
208
  "cmd.search_replace": "Cerca e sostituisci nel progetto",
@@ -231,7 +241,9 @@
231
241
  "panel.replace_all_btn": "Sostituisci tutto (Alt+Ret)",
232
242
  "panel.type_pattern": "Digita un modello di ricerca",
233
243
  "panel.matches_count": "Corrispondenze (%{count} in %{files} file)",
234
- "panel.matches_title": "Corrispondenze"
244
+ "panel.matches_title": "Corrispondenze",
245
+ "prompt.confirm_replace": "Sostituire %{count} corrispondenza/e in %{files} file? L'annulla copre solo i file aperti in questa sessione. Invio conferma, Esc annulla.",
246
+ "status.replace_cancelled": "Sostituzione annullata"
235
247
  },
236
248
  "ja": {
237
249
  "cmd.search_replace": "プロジェクト内で検索と置換",
@@ -270,7 +282,9 @@
270
282
  "panel.replace_all_btn": "すべて置換 (Alt+Ret)",
271
283
  "panel.type_pattern": "検索パターンを入力してください",
272
284
  "panel.matches_count": "一致 (%{files}ファイル中%{count}件)",
273
- "panel.matches_title": "一致"
285
+ "panel.matches_title": "一致",
286
+ "prompt.confirm_replace": "%{files} 個のファイルで %{count} 件の一致を置換しますか?元に戻せるのはこのセッションで開いているファイルのみです。Enterで確認、Escでキャンセル。",
287
+ "status.replace_cancelled": "置換がキャンセルされました"
274
288
  },
275
289
  "ko": {
276
290
  "cmd.search_replace": "프로젝트에서 검색 및 바꾸기",
@@ -309,7 +323,9 @@
309
323
  "panel.replace_all_btn": "모두 바꾸기 (Alt+Ret)",
310
324
  "panel.type_pattern": "검색 패턴을 입력하세요",
311
325
  "panel.matches_count": "일치 (%{files}개 파일에서 %{count}개)",
312
- "panel.matches_title": "일치"
326
+ "panel.matches_title": "일치",
327
+ "prompt.confirm_replace": "%{files}개 파일에서 %{count}개 일치를 바꾸시겠습니까? 실행 취소는 이 세션에서 열려 있는 파일에만 적용됩니다. Enter 확인, Esc 취소.",
328
+ "status.replace_cancelled": "바꾸기가 취소됨"
313
329
  },
314
330
  "pt-BR": {
315
331
  "cmd.search_replace": "Pesquisar e Substituir no Projeto",
@@ -348,7 +364,9 @@
348
364
  "panel.replace_all_btn": "Substituir tudo (Alt+Ret)",
349
365
  "panel.type_pattern": "Digite um padrão de pesquisa",
350
366
  "panel.matches_count": "Correspondências (%{count} em %{files} arquivos)",
351
- "panel.matches_title": "Correspondências"
367
+ "panel.matches_title": "Correspondências",
368
+ "prompt.confirm_replace": "Substituir %{count} ocorrência(s) em %{files} arquivo(s)? Desfazer só cobre arquivos abertos nesta sessão. Enter confirma, Esc cancela.",
369
+ "status.replace_cancelled": "Substituição cancelada"
352
370
  },
353
371
  "ru": {
354
372
  "cmd.search_replace": "Поиск и замена в проекте",
@@ -387,7 +405,9 @@
387
405
  "panel.replace_all_btn": "Заменить все (Alt+Ret)",
388
406
  "panel.type_pattern": "Введите шаблон поиска",
389
407
  "panel.matches_count": "Совпадения (%{count} в %{files} файлах)",
390
- "panel.matches_title": "Совпадения"
408
+ "panel.matches_title": "Совпадения",
409
+ "prompt.confirm_replace": "Заменить %{count} совпадений в %{files} файлах? Отмена применяется только к файлам, открытым в этом сеансе. Enter — подтвердить, Esc — отменить.",
410
+ "status.replace_cancelled": "Замена отменена"
391
411
  },
392
412
  "th": {
393
413
  "cmd.search_replace": "ค้นหาและแทนที่ในโปรเจกต์",
@@ -426,7 +446,9 @@
426
446
  "panel.replace_all_btn": "แทนที่ทั้งหมด (Alt+Ret)",
427
447
  "panel.type_pattern": "พิมพ์รูปแบบการค้นหา",
428
448
  "panel.matches_count": "รายการที่ตรงกัน (%{count} ใน %{files} ไฟล์)",
429
- "panel.matches_title": "รายการที่ตรงกัน"
449
+ "panel.matches_title": "รายการที่ตรงกัน",
450
+ "prompt.confirm_replace": "แทนที่ %{count} รายการใน %{files} ไฟล์? เลิกทำครอบคลุมเฉพาะไฟล์ที่เปิดอยู่ในเซสชันนี้เท่านั้น กด Enter เพื่อยืนยัน, Esc เพื่อยกเลิก",
451
+ "status.replace_cancelled": "ยกเลิกการแทนที่"
430
452
  },
431
453
  "uk": {
432
454
  "cmd.search_replace": "Пошук та заміна в проекті",
@@ -465,7 +487,9 @@
465
487
  "panel.replace_all_btn": "Замінити все (Alt+Ret)",
466
488
  "panel.type_pattern": "Введіть шаблон пошуку",
467
489
  "panel.matches_count": "Збіги (%{count} у %{files} файлах)",
468
- "panel.matches_title": "Збіги"
490
+ "panel.matches_title": "Збіги",
491
+ "prompt.confirm_replace": "Замінити %{count} збіг(ів) у %{files} файл(ах)? Скасування охоплює лише файли, відкриті в цьому сеансі. Enter — підтвердити, Esc — скасувати.",
492
+ "status.replace_cancelled": "Заміну скасовано"
469
493
  },
470
494
  "vi": {
471
495
  "cmd.search_replace": "Tìm và Thay thế trong Dự án",
@@ -504,7 +528,9 @@
504
528
  "panel.replace_all_btn": "Thay thế tất cả (Alt+Ret)",
505
529
  "panel.type_pattern": "Nhập mẫu tìm kiếm",
506
530
  "panel.matches_count": "Kết quả (%{count} trong %{files} tệp)",
507
- "panel.matches_title": "Kết quả"
531
+ "panel.matches_title": "Kết quả",
532
+ "prompt.confirm_replace": "Thay thế %{count} kết quả trong %{files} tệp? Hoàn tác chỉ áp dụng cho các tệp đang mở trong phiên này. Enter để xác nhận, Esc để hủy.",
533
+ "status.replace_cancelled": "Đã hủy thay thế"
508
534
  },
509
535
  "zh-CN": {
510
536
  "cmd.search_replace": "在项目中搜索和替换",
@@ -543,6 +569,8 @@
543
569
  "panel.replace_all_btn": "全部替换 (Alt+Ret)",
544
570
  "panel.type_pattern": "请输入搜索模式",
545
571
  "panel.matches_count": "匹配 (%{files} 个文件中 %{count} 个)",
546
- "panel.matches_title": "匹配"
572
+ "panel.matches_title": "匹配",
573
+ "prompt.confirm_replace": "在 %{files} 个文件中替换 %{count} 处匹配?撤销仅适用于本次会话中打开的文件。按 Enter 确认,Esc 取消。",
574
+ "status.replace_cancelled": "替换已取消"
547
575
  }
548
576
  }