mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d87a95725d | ||
|
|
1bbb0e1f65 | ||
|
|
f65a9ffc2d | ||
|
|
05e9447998 | ||
|
|
fbacff7983 | ||
|
|
1b188c8aad | ||
|
|
eaacd01055 | ||
|
|
2ab2321226 | ||
|
|
95e1a147d8 | ||
|
|
a1f5181e58 | ||
|
|
971f702c32 | ||
|
|
5dbdf0965a | ||
|
|
b24d6dd196 | ||
|
|
b9ba7349c1 | ||
|
|
a40c0616d7 | ||
|
|
71e5c280fd | ||
|
|
b92084c477 | ||
|
|
ae669daf19 | ||
|
|
d049b85df4 | ||
|
|
a151240017 | ||
|
|
b09ae31830 | ||
|
|
0e74c310d2 | ||
|
|
359c2b7690 | ||
|
|
0fd7bed991 | ||
|
|
f23caa5026 | ||
|
|
dd4cd0a587 | ||
|
|
54e32dbcae | ||
|
|
8fc811d3e0 | ||
|
|
1a12dfa173 | ||
|
|
97d167eef7 | ||
|
|
b852f267f8 | ||
|
|
af1b624747 | ||
|
|
ff5c062bf1 | ||
|
|
539594845f | ||
|
|
3e2fa04b08 | ||
|
|
5718566ddb | ||
|
|
e345e59219 | ||
|
|
88b0a908a9 | ||
|
|
06e8be3df9 | ||
|
|
e8f51ea63e | ||
|
|
15c8b44818 | ||
|
|
8df32d5663 | ||
|
|
00ad6198b8 | ||
|
|
0ac9ca4245 | ||
|
|
73936c7792 | ||
|
|
21a0903b32 | ||
|
|
dd45af8fe3 | ||
|
|
ada3d13ca3 | ||
|
|
c138e1fc74 | ||
|
|
600544fd7f | ||
|
|
7e379ea5f5 | ||
|
|
ac3eb88956 | ||
|
|
140d78ae9e | ||
|
|
00f91c7f07 | ||
|
|
f5c5bcd17c | ||
|
|
dc6d1d9b60 | ||
|
|
4709ff2939 | ||
|
|
e6ed93a4ac | ||
|
|
29c90f1974 | ||
|
|
0bf7e187e6 | ||
|
|
76fd52e85a | ||
|
|
9dc4336c4a | ||
|
|
bb71d28846 | ||
|
|
61b9cf2584 | ||
|
|
acfc581719 | ||
|
|
ff25cb1a71 | ||
|
|
497afbbe82 | ||
|
|
9b0c07e614 | ||
|
|
53dd8af4c0 | ||
|
|
2c4657016a | ||
|
|
2568109538 | ||
|
|
49fc6e3fba | ||
|
|
c32646b570 | ||
|
|
f034754455 | ||
|
|
e6b5b76efb | ||
|
|
0184d3c34f | ||
|
|
32afe5bf4b | ||
|
|
1f337d28ed | ||
|
|
3632b42e8e | ||
|
|
ea2c2ad301 | ||
|
|
988783c286 | ||
|
|
33de6ee388 | ||
|
|
d98cd37be6 | ||
|
|
ca344ad0a8 | ||
|
|
e7dcd22a7a | ||
|
|
d752897aa1 | ||
|
|
be6909c5ea | ||
|
|
f3e67fc0aa |
No files matched your search
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
@@ -358,6 +358,12 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Auto-import system known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'When Vault loads, silently import entries from the OpenSSH known_hosts file. Turn this off to keep Netcatty known hosts separate; Scan System still imports on demand.',
|
||||
'settings.vault.notesFont': 'Code font',
|
||||
'settings.vault.notesFontDesc': 'Font family used for code blocks and inline code in notes',
|
||||
'settings.vault.notesFontSize': 'Note font size',
|
||||
'settings.vault.notesFontSizeDesc': 'Font size used for note body text',
|
||||
'settings.vault.notesCodeFontSize': 'Code font size',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Font size used for code blocks and inline code in notes',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
|
||||
@@ -34,6 +34,61 @@ export const enVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
|
||||
'notes.empty.group': 'No notes in this notebook.',
|
||||
'notes.search.noResults': 'No notes found.',
|
||||
'notes.toolbar.modeLive': 'Live Preview',
|
||||
'notes.toolbar.modeLiveTitle': 'Live Preview Mode (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Source Mode',
|
||||
'notes.toolbar.modeSourceTitle': 'Source Mode (Markdown code)',
|
||||
'notes.toolbar.modePreview': 'Reading Mode',
|
||||
'notes.toolbar.modePreviewTitle': 'Reading Mode (Read-only)',
|
||||
'notes.toolbar.undo': 'Undo (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Redo (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Code font and typography settings',
|
||||
'notes.toolbar.customCodeFont': 'Custom Code Font',
|
||||
'notes.toolbar.fontsAvailable': '{count} fonts available',
|
||||
'notes.toolbar.searchFont': 'Search fonts...',
|
||||
'notes.toolbar.noFontsFound': 'No matching monospace fonts found',
|
||||
'notes.toolbar.defaultFont': 'Default Monospace (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Body Font Size',
|
||||
'notes.toolbar.codeFontSize': 'Code Font Size',
|
||||
'notes.toolbar.headingLevel': 'Heading Level',
|
||||
'notes.toolbar.h1': 'Heading 1',
|
||||
'notes.toolbar.h2': 'Heading 2',
|
||||
'notes.toolbar.h3': 'Heading 3',
|
||||
'notes.toolbar.h4': 'Heading 4',
|
||||
'notes.toolbar.bold': 'Bold (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Italic (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Strikethrough',
|
||||
'notes.toolbar.underline': 'Underline',
|
||||
'notes.toolbar.inlineCode': 'Inline Code',
|
||||
'notes.toolbar.bulletList': 'Bullet List',
|
||||
'notes.toolbar.orderedList': 'Numbered List',
|
||||
'notes.toolbar.taskList': 'Task List',
|
||||
'notes.toolbar.quote': 'Quote',
|
||||
'notes.toolbar.codeBlock': 'Code Block',
|
||||
'notes.toolbar.mathFormula': 'Math Formula (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Insert Table',
|
||||
'notes.toolbar.divider': 'Divider',
|
||||
'notes.toolbar.link': 'Insert Link',
|
||||
'notes.toolbar.image': 'Insert Image',
|
||||
'notes.action.pin': 'Pin Note',
|
||||
'notes.action.unpin': 'Unpin Note',
|
||||
'notes.tag.remove': 'Remove tag',
|
||||
'notes.tag.placeholder': 'Tag name...',
|
||||
'notes.tag.add': 'Add tag',
|
||||
'notes.outline.title': 'Table of Contents',
|
||||
'notes.outline.titleWithCount': 'Table of Contents ({count})',
|
||||
'notes.outline.empty': 'No outline headings',
|
||||
'notes.outline.emptyHint': 'Use # and ## in Markdown to create headings',
|
||||
'notes.export.share': 'Export & Share',
|
||||
'notes.export.currentNote': 'Current Note',
|
||||
'notes.export.exportMarkdown': 'Export as Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Export as HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Copy Markdown Source',
|
||||
'notes.export.allNotes': 'All Notes',
|
||||
'notes.export.exportAllZip': 'Export All as ZIP Archive',
|
||||
'notes.export.toast.markdownSuccess': 'Markdown file exported',
|
||||
'notes.export.toast.htmlSuccess': 'HTML file exported',
|
||||
'notes.export.toast.zipSuccess': 'All notes exported as ZIP backup',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -397,12 +452,12 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.folderTitle': 'Folder Conflict',
|
||||
'sftp.conflict.folderDesc': 'A folder with the same name already exists at the destination',
|
||||
'sftp.conflict.folderDesc': 'Choose how to continue.',
|
||||
'sftp.conflict.folderFileDesc': 'A file with the same name already exists at the destination. A folder cannot be merged with a file.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'A symbolic link with the same name already exists at the destination. A folder cannot be merged with a symbolic link.',
|
||||
'sftp.conflict.folderUnknownDesc': 'An item with the same name exists at the destination, but its type could not be confirmed. Replace is unavailable to protect destination data.',
|
||||
'sftp.conflict.folderMergeHint': 'Merge keeps files and sub-folders that exist only in the destination.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Replace deletes all destination files and sub-folders that are not in the source. Deleted data cannot be recovered.',
|
||||
'sftp.conflict.folderMergeHint': 'Merge: keeps destination-only content.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Replace: deletes destination-only content and cannot be undone.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
'sftp.conflict.existingFile': 'Existing file',
|
||||
'sftp.conflict.newFile': 'New file',
|
||||
|
||||
@@ -279,6 +279,12 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Muestra la barra lateral de lista de hosts y su alternador en la barra superior en las pestañas de terminal y editor.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Importar automáticamente known_hosts del sistema',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'Cuando se carga el Vault, importa en silencio las entradas del archivo known_hosts de OpenSSH. Desactívalo para mantener los hosts conocidos de Netcatty separados; Escanear sistema sigue importando a pedido.',
|
||||
'settings.vault.notesFont': 'Fuente de código',
|
||||
'settings.vault.notesFontDesc': 'Familia tipográfica para bloques de código y código en línea en notas',
|
||||
'settings.vault.notesFontSize': 'Tamaño de fuente de notas',
|
||||
'settings.vault.notesFontSizeDesc': 'Tamaño de fuente para el cuerpo de texto de las notas',
|
||||
'settings.vault.notesCodeFontSize': 'Tamaño de fuente de código',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Tamaño de fuente para bloques de código y código en línea en notas',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Actualización disponible',
|
||||
|
||||
@@ -34,6 +34,61 @@ export const esVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
|
||||
'notes.empty.group': 'No hay notas en este cuaderno.',
|
||||
'notes.search.noResults': 'No se encontraron notas.',
|
||||
'notes.toolbar.modeLive': 'Vista en vivo',
|
||||
'notes.toolbar.modeLiveTitle': 'Modo vista en vivo (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Modo fuente',
|
||||
'notes.toolbar.modeSourceTitle': 'Modo fuente (código Markdown)',
|
||||
'notes.toolbar.modePreview': 'Modo lectura',
|
||||
'notes.toolbar.modePreviewTitle': 'Modo lectura (solo lectura)',
|
||||
'notes.toolbar.undo': 'Deshacer (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Rehacer (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Fuente de código y ajustes de tipografía',
|
||||
'notes.toolbar.customCodeFont': 'Fuente de código personalizada',
|
||||
'notes.toolbar.fontsAvailable': '{count} fuentes disponibles',
|
||||
'notes.toolbar.searchFont': 'Buscar fuentes...',
|
||||
'notes.toolbar.noFontsFound': 'No se encontraron fuentes monoespaciadas coincidentes',
|
||||
'notes.toolbar.defaultFont': 'Monoespaciado predeterminado (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Tamaño de texto',
|
||||
'notes.toolbar.codeFontSize': 'Tamaño de código',
|
||||
'notes.toolbar.headingLevel': 'Nivel de encabezado',
|
||||
'notes.toolbar.h1': 'Encabezado 1',
|
||||
'notes.toolbar.h2': 'Encabezado 2',
|
||||
'notes.toolbar.h3': 'Encabezado 3',
|
||||
'notes.toolbar.h4': 'Encabezado 4',
|
||||
'notes.toolbar.bold': 'Negrita (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Cursiva (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Tachado',
|
||||
'notes.toolbar.underline': 'Subrayado',
|
||||
'notes.toolbar.inlineCode': 'Código en línea',
|
||||
'notes.toolbar.bulletList': 'Lista con viñetas',
|
||||
'notes.toolbar.orderedList': 'Lista numerada',
|
||||
'notes.toolbar.taskList': 'Lista de tareas',
|
||||
'notes.toolbar.quote': 'Cita',
|
||||
'notes.toolbar.codeBlock': 'Bloque de código',
|
||||
'notes.toolbar.mathFormula': 'Fórmula matemática (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Insertar tabla',
|
||||
'notes.toolbar.divider': 'Línea divisoria',
|
||||
'notes.toolbar.link': 'Insertar enlace',
|
||||
'notes.toolbar.image': 'Insertar imagen',
|
||||
'notes.action.pin': 'Fijar nota',
|
||||
'notes.action.unpin': 'Desfijar nota',
|
||||
'notes.tag.remove': 'Eliminar etiqueta',
|
||||
'notes.tag.placeholder': 'Nombre de etiqueta...',
|
||||
'notes.tag.add': 'Añadir etiqueta',
|
||||
'notes.outline.title': 'Índice de contenido',
|
||||
'notes.outline.titleWithCount': 'Índice de contenido ({count})',
|
||||
'notes.outline.empty': 'Sin encabezados de esquema',
|
||||
'notes.outline.emptyHint': 'Use # y ## en Markdown para crear encabezados',
|
||||
'notes.export.share': 'Exportar y compartir',
|
||||
'notes.export.currentNote': 'Nota actual',
|
||||
'notes.export.exportMarkdown': 'Exportar como Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Exportar como HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Copiar código Markdown',
|
||||
'notes.export.allNotes': 'Todas las notas',
|
||||
'notes.export.exportAllZip': 'Exportar todo como archivo ZIP',
|
||||
'notes.export.toast.markdownSuccess': 'Archivo Markdown exportado',
|
||||
'notes.export.toast.htmlSuccess': 'Archivo HTML exportado',
|
||||
'notes.export.toast.zipSuccess': 'Todas las notas exportadas como copia de seguridad ZIP',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Agrega datos a tu Vault',
|
||||
@@ -397,12 +452,12 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.conflict.title': 'Conflicto de archivos',
|
||||
'sftp.conflict.desc': 'Ya existe un archivo con el mismo nombre en el destino',
|
||||
'sftp.conflict.folderTitle': 'Conflicto de carpetas',
|
||||
'sftp.conflict.folderDesc': 'Ya existe una carpeta con el mismo nombre en el destino',
|
||||
'sftp.conflict.folderDesc': 'Elige cómo continuar.',
|
||||
'sftp.conflict.folderFileDesc': 'Ya existe un archivo con el mismo nombre en el destino. Una carpeta no se puede combinar con un archivo.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'Ya existe un enlace simbólico con el mismo nombre en el destino. Una carpeta no se puede combinar con un enlace simbólico.',
|
||||
'sftp.conflict.folderUnknownDesc': 'Ya existe un elemento con el mismo nombre en el destino, pero no se pudo confirmar su tipo. Reemplazar no está disponible para proteger los datos del destino.',
|
||||
'sftp.conflict.folderMergeHint': 'Combinar conserva los archivos y subcarpetas que solo existen en el destino.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Reemplazar elimina todos los archivos y subcarpetas del destino que no estén en el origen. Los datos eliminados no se pueden recuperar.',
|
||||
'sftp.conflict.folderMergeHint': 'Combinar: conserva el contenido que solo existe en el destino.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Reemplazar: elimina el contenido exclusivo del destino y no se puede deshacer.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'ya existe',
|
||||
'sftp.conflict.existingFile': 'Archivo existente',
|
||||
'sftp.conflict.newFile': 'Archivo nuevo',
|
||||
|
||||
@@ -342,6 +342,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Автоимпорт системного known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'При открытии хранилища тихо импортировать записи из OpenSSH known_hosts. Отключите, чтобы держать известные хосты Netcatty отдельно; кнопка «Сканировать систему» по-прежнему импортирует вручную.',
|
||||
'settings.vault.notesFont': 'Шрифт кода',
|
||||
'settings.vault.notesFontDesc': 'Шрифт для блоков кода и встроенного кода в заметках',
|
||||
'settings.vault.notesFontSize': 'Размер шрифта заметок',
|
||||
'settings.vault.notesFontSizeDesc': 'Размер шрифта для основного текста заметок',
|
||||
'settings.vault.notesCodeFontSize': 'Размер шрифта кода',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Размер шрифта для блоков кода и встроенного кода в заметках',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Доступно обновление',
|
||||
|
||||
@@ -34,6 +34,61 @@ export const ruVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
|
||||
'notes.empty.group': 'В этом блокноте пока нет заметок.',
|
||||
'notes.search.noResults': 'Заметки не найдены.',
|
||||
'notes.toolbar.modeLive': 'Живой просмотр',
|
||||
'notes.toolbar.modeLiveTitle': 'Режим живого просмотра (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Исходный код',
|
||||
'notes.toolbar.modeSourceTitle': 'Режим исходного кода (Markdown)',
|
||||
'notes.toolbar.modePreview': 'Чтение',
|
||||
'notes.toolbar.modePreviewTitle': 'Режим чтения (только для чтения)',
|
||||
'notes.toolbar.undo': 'Отменить (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Повторить (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Шрифт кода и параметры оформления',
|
||||
'notes.toolbar.customCodeFont': 'Пользовательский шрифт кода',
|
||||
'notes.toolbar.fontsAvailable': '{count} доступных шрифтов',
|
||||
'notes.toolbar.searchFont': 'Поиск шрифтов...',
|
||||
'notes.toolbar.noFontsFound': 'Совпадающие моноширинные шрифты не найдены',
|
||||
'notes.toolbar.defaultFont': 'Моноширинный по умолчанию (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Размер текста',
|
||||
'notes.toolbar.codeFontSize': 'Размер кода',
|
||||
'notes.toolbar.headingLevel': 'Уровень заголовка',
|
||||
'notes.toolbar.h1': 'Заголовок 1',
|
||||
'notes.toolbar.h2': 'Заголовок 2',
|
||||
'notes.toolbar.h3': 'Заголовок 3',
|
||||
'notes.toolbar.h4': 'Заголовок 4',
|
||||
'notes.toolbar.bold': 'Жирный (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Курсив (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Зачёркнутый',
|
||||
'notes.toolbar.underline': 'Подчёркнутый',
|
||||
'notes.toolbar.inlineCode': 'Встроенный код',
|
||||
'notes.toolbar.bulletList': 'Маркированный список',
|
||||
'notes.toolbar.orderedList': 'Нумерованный список',
|
||||
'notes.toolbar.taskList': 'Список задач',
|
||||
'notes.toolbar.quote': 'Цитата',
|
||||
'notes.toolbar.codeBlock': 'Блок кода',
|
||||
'notes.toolbar.mathFormula': 'Математическая формула (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Вставить таблицу',
|
||||
'notes.toolbar.divider': 'Разделитель',
|
||||
'notes.toolbar.link': 'Вставить ссылку',
|
||||
'notes.toolbar.image': 'Вставить изображение',
|
||||
'notes.action.pin': 'Закрепить заметку',
|
||||
'notes.action.unpin': 'Открепить заметку',
|
||||
'notes.tag.remove': 'Удалить тег',
|
||||
'notes.tag.placeholder': 'Имя тега...',
|
||||
'notes.tag.add': 'Добавить тег',
|
||||
'notes.outline.title': 'Оглавление',
|
||||
'notes.outline.titleWithCount': 'Оглавление ({count})',
|
||||
'notes.outline.empty': 'Нет заголовков',
|
||||
'notes.outline.emptyHint': 'Используйте # и ## в Markdown для создания заголовков',
|
||||
'notes.export.share': 'Экспорт и общий доступ',
|
||||
'notes.export.currentNote': 'Текущая заметка',
|
||||
'notes.export.exportMarkdown': 'Экспорт в Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Экспорт в HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Копировать исходный Markdown',
|
||||
'notes.export.allNotes': 'Все заметки',
|
||||
'notes.export.exportAllZip': 'Экспортировать все в ZIP-архив',
|
||||
'notes.export.toast.markdownSuccess': 'Markdown-файл экспортирован',
|
||||
'notes.export.toast.htmlSuccess': 'HTML-файл экспортирован',
|
||||
'notes.export.toast.zipSuccess': 'Все заметки экспортированы в архив ZIP',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
@@ -433,12 +488,12 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.conflict.title': 'Конфликт файлов',
|
||||
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
|
||||
'sftp.conflict.folderTitle': 'Конфликт папок',
|
||||
'sftp.conflict.folderDesc': 'В месте назначения уже существует папка с таким именем',
|
||||
'sftp.conflict.folderDesc': 'Выберите действие.',
|
||||
'sftp.conflict.folderFileDesc': 'В месте назначения уже существует файл с таким именем. Папку нельзя объединить с файлом.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'В месте назначения уже существует символическая ссылка с таким именем. Папку нельзя объединить с символической ссылкой.',
|
||||
'sftp.conflict.folderUnknownDesc': 'В месте назначения уже существует элемент с таким именем, но его тип не удалось определить. Замена недоступна для защиты данных в месте назначения.',
|
||||
'sftp.conflict.folderMergeHint': 'Объединение сохраняет файлы и подпапки, которые существуют только в папке назначения.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Замена удалит все файлы и подпапки в папке назначения, которых нет в исходной папке. Удалённые данные нельзя восстановить.',
|
||||
'sftp.conflict.folderMergeHint': 'Объединить: сохранить содержимое, которое есть только в папке назначения.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Заменить: удалить содержимое, которое есть только в папке назначения. Это нельзя отменить.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
|
||||
'sftp.conflict.existingFile': 'Существующий файл',
|
||||
'sftp.conflict.newFile': 'Новый файл',
|
||||
|
||||
@@ -355,6 +355,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自动导入系统 known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': '打开主机库时静默导入 OpenSSH known_hosts 中的记录。关闭后,Netcatty 已知主机与系统信任库保持独立;仍可通过「扫描系统」手动导入。',
|
||||
'settings.vault.notesFont': '代码字体',
|
||||
'settings.vault.notesFontDesc': '自定义笔记中代码块与行内代码使用的字体',
|
||||
'settings.vault.notesFontSize': '笔记字号',
|
||||
'settings.vault.notesFontSizeDesc': '自定义笔记正文字体大小',
|
||||
'settings.vault.notesCodeFontSize': '代码字号',
|
||||
'settings.vault.notesCodeFontSizeDesc': '自定义笔记中代码块与行内代码的字体大小',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
|
||||
@@ -34,6 +34,61 @@ export const zhCNVaultMessages: Messages = {
|
||||
'notes.empty.desc': '可以记录运维手册、临时备忘、服务器交接信息。',
|
||||
'notes.empty.group': '这个笔记本里还没有笔记。',
|
||||
'notes.search.noResults': '没有找到笔记。',
|
||||
'notes.toolbar.modeLive': '实时预览',
|
||||
'notes.toolbar.modeLiveTitle': '实时预览模式 (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': '源码模式',
|
||||
'notes.toolbar.modeSourceTitle': '源码模式 (Markdown 代码)',
|
||||
'notes.toolbar.modePreview': '阅读模式',
|
||||
'notes.toolbar.modePreviewTitle': '阅读模式 (只读排版)',
|
||||
'notes.toolbar.undo': '撤销 (Ctrl+Z)',
|
||||
'notes.toolbar.redo': '重做 (Ctrl+Y)',
|
||||
'notes.toolbar.typography': '代码字体与排版设置',
|
||||
'notes.toolbar.customCodeFont': '自定义代码字体',
|
||||
'notes.toolbar.fontsAvailable': '共 {count} 款可用字体',
|
||||
'notes.toolbar.searchFont': '搜索字体...',
|
||||
'notes.toolbar.noFontsFound': '未找到匹配的等宽字体',
|
||||
'notes.toolbar.defaultFont': '默认等宽字体 (Default)',
|
||||
'notes.toolbar.bodyFontSize': '正文字号',
|
||||
'notes.toolbar.codeFontSize': '代码字号',
|
||||
'notes.toolbar.headingLevel': '标题层级',
|
||||
'notes.toolbar.h1': '一级标题',
|
||||
'notes.toolbar.h2': '二级标题',
|
||||
'notes.toolbar.h3': '三级标题',
|
||||
'notes.toolbar.h4': '四级标题',
|
||||
'notes.toolbar.bold': '加粗 (Ctrl+B)',
|
||||
'notes.toolbar.italic': '斜体 (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': '删除线',
|
||||
'notes.toolbar.underline': '下划线',
|
||||
'notes.toolbar.inlineCode': '行内代码',
|
||||
'notes.toolbar.bulletList': '无序列表',
|
||||
'notes.toolbar.orderedList': '有序列表',
|
||||
'notes.toolbar.taskList': '待办任务清单',
|
||||
'notes.toolbar.quote': '引用块',
|
||||
'notes.toolbar.codeBlock': '代码块',
|
||||
'notes.toolbar.mathFormula': '数学公式块 (LaTeX / Math)',
|
||||
'notes.toolbar.table': '插入表格',
|
||||
'notes.toolbar.divider': '分割线',
|
||||
'notes.toolbar.link': '插入超链接',
|
||||
'notes.toolbar.image': '插入图片',
|
||||
'notes.action.pin': '置顶笔记',
|
||||
'notes.action.unpin': '取消置顶',
|
||||
'notes.tag.remove': '移除标签',
|
||||
'notes.tag.placeholder': '标签名...',
|
||||
'notes.tag.add': '添加标签',
|
||||
'notes.outline.title': '大纲目录',
|
||||
'notes.outline.titleWithCount': '大纲目录 ({count})',
|
||||
'notes.outline.empty': '暂无标题大纲',
|
||||
'notes.outline.emptyHint': '在 Markdown 中使用 #、## 编写各级标题',
|
||||
'notes.export.share': '导出与分享',
|
||||
'notes.export.currentNote': '当前笔记',
|
||||
'notes.export.exportMarkdown': '导出为 Markdown (.md)',
|
||||
'notes.export.exportHtml': '导出为 HTML (.html)',
|
||||
'notes.export.copyMarkdown': '复制 Markdown 源码',
|
||||
'notes.export.allNotes': '全部笔记',
|
||||
'notes.export.exportAllZip': '导出全部为 ZIP 压缩包',
|
||||
'notes.export.toast.markdownSuccess': '已导出 Markdown 文件',
|
||||
'notes.export.toast.htmlSuccess': '已导出 HTML 文件',
|
||||
'notes.export.toast.zipSuccess': '已导出全部笔记备份 ZIP',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
@@ -912,12 +967,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.folderTitle': '文件夹冲突',
|
||||
'sftp.conflict.folderDesc': '目标位置已存在同名文件夹',
|
||||
'sftp.conflict.folderDesc': '请选择处理方式。',
|
||||
'sftp.conflict.folderFileDesc': '目标位置已存在同名文件,文件夹无法与文件合并。',
|
||||
'sftp.conflict.folderSymlinkDesc': '目标位置已存在同名符号链接,文件夹无法与符号链接合并。',
|
||||
'sftp.conflict.folderUnknownDesc': '目标位置已存在同名项目,但无法确认其类型。为保护目标数据,替换操作不可用。',
|
||||
'sftp.conflict.folderMergeHint': '合并会保留仅存在于目标文件夹中的文件和子文件夹。',
|
||||
'sftp.conflict.folderReplaceWarning': '替换会删除目标文件夹中所有未出现在源文件夹里的文件和子文件夹,删除的数据无法恢复。',
|
||||
'sftp.conflict.folderMergeHint': '合并:保留目标中独有的内容。',
|
||||
'sftp.conflict.folderReplaceWarning': '替换:删除目标中独有的内容,且无法恢复。',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
'sftp.conflict.existingFile': '已有文件',
|
||||
'sftp.conflict.newFile': '新文件',
|
||||
|
||||
@@ -358,6 +358,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器分頁顯示主機列表側欄及頂部開關。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自動匯入系統 known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': '開啟主機庫時靜默匯入 OpenSSH known_hosts 中的記錄。關閉後,Netcatty 已知主機與系統信任庫保持獨立;仍可透過「掃描系統」手動匯入。',
|
||||
'settings.vault.notesFont': '程式碼字型',
|
||||
'settings.vault.notesFontDesc': '自訂筆記中程式碼區塊與行內程式碼使用的字型',
|
||||
'settings.vault.notesFontSize': '筆記字級',
|
||||
'settings.vault.notesFontSizeDesc': '自訂筆記內文字型大小',
|
||||
'settings.vault.notesCodeFontSize': '程式碼字級',
|
||||
'settings.vault.notesCodeFontSizeDesc': '自訂筆記中程式碼區塊與行內程式碼的文字大小',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '發現新版本',
|
||||
|
||||
@@ -34,6 +34,61 @@ export const zhTWVaultMessages: Messages = {
|
||||
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
|
||||
'notes.empty.group': '這個筆記本裡還沒有筆記。',
|
||||
'notes.search.noResults': '沒有找到筆記。',
|
||||
'notes.toolbar.modeLive': '即時預覽',
|
||||
'notes.toolbar.modeLiveTitle': '即時預覽模式 (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': '原始碼模式',
|
||||
'notes.toolbar.modeSourceTitle': '原始碼模式 (Markdown 程式碼)',
|
||||
'notes.toolbar.modePreview': '閱讀模式',
|
||||
'notes.toolbar.modePreviewTitle': '閱讀模式 (唯讀排版)',
|
||||
'notes.toolbar.undo': '復原 (Ctrl+Z)',
|
||||
'notes.toolbar.redo': '重做 (Ctrl+Y)',
|
||||
'notes.toolbar.typography': '程式碼字型與排版設定',
|
||||
'notes.toolbar.customCodeFont': '自訂程式碼字型',
|
||||
'notes.toolbar.fontsAvailable': '共 {count} 款可用字型',
|
||||
'notes.toolbar.searchFont': '搜尋字型...',
|
||||
'notes.toolbar.noFontsFound': '未找到相符的等寬字型',
|
||||
'notes.toolbar.defaultFont': '預設等寬字型 (Default)',
|
||||
'notes.toolbar.bodyFontSize': '內文字級',
|
||||
'notes.toolbar.codeFontSize': '程式碼字級',
|
||||
'notes.toolbar.headingLevel': '標題層級',
|
||||
'notes.toolbar.h1': '一級標題',
|
||||
'notes.toolbar.h2': '二級標題',
|
||||
'notes.toolbar.h3': '三級標題',
|
||||
'notes.toolbar.h4': '四級標題',
|
||||
'notes.toolbar.bold': '粗體 (Ctrl+B)',
|
||||
'notes.toolbar.italic': '斜體 (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': '刪除線',
|
||||
'notes.toolbar.underline': '底線',
|
||||
'notes.toolbar.inlineCode': '行內程式碼',
|
||||
'notes.toolbar.bulletList': '無序列表',
|
||||
'notes.toolbar.orderedList': '有序列表',
|
||||
'notes.toolbar.taskList': '待辦任務清單',
|
||||
'notes.toolbar.quote': '引用區塊',
|
||||
'notes.toolbar.codeBlock': '程式碼區塊',
|
||||
'notes.toolbar.mathFormula': '數學公式區塊 (LaTeX / Math)',
|
||||
'notes.toolbar.table': '插入表格',
|
||||
'notes.toolbar.divider': '分隔線',
|
||||
'notes.toolbar.link': '插入超連結',
|
||||
'notes.toolbar.image': '插入圖片',
|
||||
'notes.action.pin': '置頂筆記',
|
||||
'notes.action.unpin': '取消置頂',
|
||||
'notes.tag.remove': '移除標籤',
|
||||
'notes.tag.placeholder': '標籤名...',
|
||||
'notes.tag.add': '新增標籤',
|
||||
'notes.outline.title': '大綱目錄',
|
||||
'notes.outline.titleWithCount': '大綱目錄 ({count})',
|
||||
'notes.outline.empty': '暫無標題大綱',
|
||||
'notes.outline.emptyHint': '在 Markdown 中使用 #、## 編寫各級標題',
|
||||
'notes.export.share': '匯出與分享',
|
||||
'notes.export.currentNote': '目前筆記',
|
||||
'notes.export.exportMarkdown': '匯出為 Markdown (.md)',
|
||||
'notes.export.exportHtml': '匯出為 HTML (.html)',
|
||||
'notes.export.copyMarkdown': '複製 Markdown 源碼',
|
||||
'notes.export.allNotes': '全部筆記',
|
||||
'notes.export.exportAllZip': '匯出全部為 ZIP 壓縮包',
|
||||
'notes.export.toast.markdownSuccess': '已匯出 Markdown 檔案',
|
||||
'notes.export.toast.htmlSuccess': '已匯出 HTML 檔案',
|
||||
'notes.export.toast.zipSuccess': '已匯出全部筆記備份 ZIP',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '選擇主機',
|
||||
@@ -912,12 +967,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'sftp.conflict.title': '檔案衝突',
|
||||
'sftp.conflict.desc': '目標位置已存在同名檔案',
|
||||
'sftp.conflict.folderTitle': '資料夾衝突',
|
||||
'sftp.conflict.folderDesc': '目標位置已存在同名資料夾',
|
||||
'sftp.conflict.folderDesc': '請選擇處理方式。',
|
||||
'sftp.conflict.folderFileDesc': '目標位置已存在同名檔案,資料夾無法與檔案合併。',
|
||||
'sftp.conflict.folderSymlinkDesc': '目標位置已存在同名符號連結,資料夾無法與符號連結合併。',
|
||||
'sftp.conflict.folderUnknownDesc': '目標位置已存在同名項目,但無法確認其類型。為保護目標資料,替換操作不可用。',
|
||||
'sftp.conflict.folderMergeHint': '合併會保留只存在於目標資料夾中的檔案和子資料夾。',
|
||||
'sftp.conflict.folderReplaceWarning': '替換會刪除目標資料夾中所有未出現在來源資料夾裡的檔案和子資料夾,刪除的資料無法復原。',
|
||||
'sftp.conflict.folderMergeHint': '合併:保留目標中獨有的內容。',
|
||||
'sftp.conflict.folderReplaceWarning': '替換:刪除目標中獨有的內容,且無法復原。',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
'sftp.conflict.existingFile': '已有檔案',
|
||||
'sftp.conflict.newFile': '新檔案',
|
||||
|
||||
@@ -154,7 +154,8 @@ export const useAvailableFonts = (): TerminalFont[] => {
|
||||
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getAvailableFonts
|
||||
fontStore.getAvailableFonts,
|
||||
fontStore.getAvailableFonts,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -165,6 +166,7 @@ export const useInstalledFontFamilies = (): string[] | null => {
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getInstalledFontFamilies,
|
||||
fontStore.getInstalledFontFamilies,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import { resolveInheritedAIActiveSessionId } from '../../domain/aiWorkspaceScopeInherit.ts';
|
||||
import { resolveDisplayedPanelView } from '../../components/ai/aiPanelViewState.ts';
|
||||
import type { AISession } from '../../infrastructure/ai/types.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
|
||||
const SCOPE_KEY = 'workspace:workspace-old';
|
||||
const RESTORED_SESSION: AISession = {
|
||||
id: 'chat-old',
|
||||
title: 'Restored chat',
|
||||
agentId: 'catty',
|
||||
scope: {
|
||||
type: 'terminal',
|
||||
targetId: 'terminal-a',
|
||||
hostIds: ['host-a'],
|
||||
},
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'old question', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'old answer', timestamp: 2 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
|
||||
function NewChatHarness() {
|
||||
const ai = useAIState();
|
||||
const state = useAISessionsStore();
|
||||
const visibleSessionIds = new Set(state.sessions.map((session) => session.id));
|
||||
const inheritedSessionId = resolveInheritedAIActiveSessionId({
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: 'workspace-old',
|
||||
activeSessionIdMap: state.activeSessionIdMap,
|
||||
memberTerminalIds: ['terminal-a', 'terminal-b'],
|
||||
preferredTerminalId: 'terminal-a',
|
||||
visibleSessionIds,
|
||||
});
|
||||
const view = resolveDisplayedPanelView(
|
||||
state.panelViewByScope[SCOPE_KEY],
|
||||
state.draftsByScope[SCOPE_KEY] != null,
|
||||
state.sessions as AISession[],
|
||||
inheritedSessionId,
|
||||
'workspace',
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-view={view.mode}
|
||||
data-session={view.mode === 'session' ? view.sessionId : ''}
|
||||
onClick={() => {
|
||||
ai.clearDraftForScope(SCOPE_KEY);
|
||||
ai.updateDraft(SCOPE_KEY, 'catty', () => ({
|
||||
text: '',
|
||||
agentId: 'catty',
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
ai.showDraftView(SCOPE_KEY);
|
||||
}}
|
||||
>
|
||||
New Chat
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test('restored workspace enters a blank draft when New Chat is clicked', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: dom.window,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: dom.window.document,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: dom.window.localStorage,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||
configurable: true,
|
||||
value: dom.window.CustomEvent,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Event', {
|
||||
configurable: true,
|
||||
value: dom.window.Event,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify([RESTORED_SESSION]));
|
||||
dom.window.localStorage.setItem('netcatty_ai_active_session_map_v1', JSON.stringify({
|
||||
'terminal:terminal-a': RESTORED_SESSION.id,
|
||||
[SCOPE_KEY]: RESTORED_SESSION.id,
|
||||
}));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<NewChatHarness />);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
assert.ok(button);
|
||||
assert.equal(button.dataset.view, 'session');
|
||||
assert.equal(button.dataset.session, RESTORED_SESSION.id);
|
||||
|
||||
await act(async () => {
|
||||
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
assert.equal(button.dataset.view, 'draft');
|
||||
assert.equal(button.dataset.session, '');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
@@ -951,34 +951,25 @@ export function useAIState() {
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
let panelViewChanged = false;
|
||||
const currentActiveSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const next = activateDraftView(
|
||||
currentActiveSessionIdMap,
|
||||
panelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
|
||||
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
|
||||
const next = activateDraftView(
|
||||
prevActiveSessionIdMap,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextActiveSessionIdMap = next.activeSessionIdMap;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
|
||||
});
|
||||
|
||||
if (activeSessionMapChanged && nextActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
if (next.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(next.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next.activeSessionIdMap);
|
||||
setActiveSessionIdMapRaw(next.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
if (next.panelViewByScope !== panelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(next.panelViewByScope);
|
||||
setPanelViewByScopeRaw(next.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createStoredNumberSyncHandler } from "./useStoredNumber.ts";
|
||||
|
||||
test("stored number instances adopt same-window adapter changes", () => {
|
||||
let current = 14;
|
||||
let received = 14;
|
||||
const handler = createStoredNumberSyncHandler({
|
||||
storageKey: "note-size",
|
||||
readValue: () => current,
|
||||
onValue: (value) => { received = value; },
|
||||
});
|
||||
|
||||
current = 18;
|
||||
handler(new CustomEvent("netcatty:local-storage-adapter-changed", { detail: { key: "other" } }));
|
||||
assert.equal(received, 14);
|
||||
handler(new CustomEvent("netcatty:local-storage-adapter-changed", { detail: { key: "note-size" } }));
|
||||
assert.equal(received, 18);
|
||||
});
|
||||
@@ -1,5 +1,35 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const readStoredNumberValue = (
|
||||
storageKey: string,
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
): number => {
|
||||
if (typeof globalThis.localStorage === "undefined") return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
};
|
||||
|
||||
export const createStoredNumberSyncHandler = ({
|
||||
storageKey,
|
||||
readValue,
|
||||
onValue,
|
||||
}: {
|
||||
storageKey: string;
|
||||
readValue: () => number;
|
||||
onValue: (value: number) => void;
|
||||
}) => (event: Event) => {
|
||||
const key = event.type === "storage"
|
||||
? (event as StorageEvent).key
|
||||
: (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === storageKey) onValue(readValue());
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for reading a number from localStorage with lazy persistence.
|
||||
@@ -13,13 +43,32 @@ export const useStoredNumber = (
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
) => {
|
||||
const [value, setValue] = useState<number>(() => {
|
||||
if (typeof globalThis.localStorage === "undefined") return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
});
|
||||
const clampMin = clamp?.min;
|
||||
const clampMax = clamp?.max;
|
||||
const readValue = useCallback(
|
||||
() => readStoredNumberValue(
|
||||
storageKey,
|
||||
fallback,
|
||||
clampMin != null && clampMax != null ? { min: clampMin, max: clampMax } : undefined,
|
||||
),
|
||||
[clampMax, clampMin, fallback, storageKey],
|
||||
);
|
||||
const [value, setValue] = useState<number>(readValue);
|
||||
|
||||
useEffect(() => {
|
||||
const target = globalThis as typeof globalThis & {
|
||||
addEventListener?: (type: string, listener: EventListener) => void;
|
||||
removeEventListener?: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
if (typeof target.addEventListener !== "function") return;
|
||||
const sync = createStoredNumberSyncHandler({ storageKey, readValue, onValue: setValue });
|
||||
target.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
|
||||
target.addEventListener("storage", sync);
|
||||
return () => {
|
||||
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
|
||||
target.removeEventListener?.("storage", sync);
|
||||
};
|
||||
}, [readValue, storageKey]);
|
||||
|
||||
const persist = useCallback(
|
||||
(v: number) => {
|
||||
|
||||
@@ -104,3 +104,16 @@ test("stored string helpers read fallback and resolve updater-style toggles", (t
|
||||
"edit",
|
||||
);
|
||||
});
|
||||
|
||||
test("stored string helpers work with default validator when isAllowedValue is omitted", (t) => {
|
||||
const env = installLocalStorage();
|
||||
t.after(() => env.restore());
|
||||
|
||||
const storageKey = "netcatty:test-font";
|
||||
assert.equal(readStoredStringValue(storageKey, "default-font"), "default-font");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey), null);
|
||||
|
||||
env.storage.setItem(storageKey, "Inter");
|
||||
assert.equal(readStoredStringValue(storageKey, "default-font"), "Inter");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey), "Inter");
|
||||
});
|
||||
@@ -8,23 +8,27 @@ type StoredStringSetter<T extends string> = (nextValue: T | ((currentValue: T) =
|
||||
|
||||
const canUseLocalStorage = () => typeof globalThis.localStorage !== "undefined";
|
||||
|
||||
export const readStoredStringValue = <T extends string>(
|
||||
const defaultIsAllowedString = <T extends string>(value: string | null): value is T => typeof value === "string";
|
||||
|
||||
export const readStoredStringValue = <T extends string = string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
isAllowedValue: (value: string | null) => value is T = defaultIsAllowedString,
|
||||
): T => {
|
||||
if (!canUseLocalStorage()) return fallback;
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
return isAllowedValue(stored) ? stored : fallback;
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
return validator(stored) ? stored : fallback;
|
||||
};
|
||||
|
||||
export const readOptionalStoredStringValue = <T extends string>(
|
||||
export const readOptionalStoredStringValue = <T extends string = string>(
|
||||
storageKey: string,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
isAllowedValue: (value: string | null) => value is T = defaultIsAllowedString,
|
||||
): T | null => {
|
||||
if (!canUseLocalStorage()) return null;
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
return isAllowedValue(stored) ? stored : null;
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
return validator(stored) ? stored : null;
|
||||
};
|
||||
|
||||
export const resolveStoredStringUpdate = <T extends string>(
|
||||
@@ -47,11 +51,12 @@ export const createStoredStringSyncHandlers = <T extends string>({
|
||||
}: {
|
||||
storageKey: string;
|
||||
fallback: T;
|
||||
isAllowedValue: (value: string | null) => value is T;
|
||||
isAllowedValue?: (value: string | null) => value is T;
|
||||
onValue: (value: T) => void;
|
||||
}) => {
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
const syncFromStorage = () => {
|
||||
onValue(readStoredStringValue(storageKey, fallback, isAllowedValue));
|
||||
onValue(readStoredStringValue(storageKey, fallback, validator));
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -64,15 +69,16 @@ export const createStoredStringSyncHandlers = <T extends string>({
|
||||
};
|
||||
};
|
||||
|
||||
export const useStoredString = <T extends string>(
|
||||
export const useStoredString = <T extends string = string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
isAllowedValue?: (value: string | null) => value is T,
|
||||
) => {
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
const [value, setValue] = useState<T>(() => readStoredStringValue(
|
||||
storageKey,
|
||||
fallback,
|
||||
isAllowedValue,
|
||||
validator,
|
||||
));
|
||||
|
||||
const setAndPersist = useCallback<StoredStringSetter<T>>((nextValue) => {
|
||||
@@ -98,7 +104,7 @@ export const useStoredString = <T extends string>(
|
||||
} = createStoredStringSyncHandlers({
|
||||
storageKey,
|
||||
fallback,
|
||||
isAllowedValue,
|
||||
isAllowedValue: validator,
|
||||
onValue: setValue,
|
||||
});
|
||||
|
||||
@@ -108,7 +114,7 @@ export const useStoredString = <T extends string>(
|
||||
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleAdapterChange);
|
||||
target.removeEventListener?.("storage", handleBrowserStorage);
|
||||
};
|
||||
}, [fallback, isAllowedValue, storageKey]);
|
||||
}, [fallback, storageKey, validator]);
|
||||
|
||||
return [value, setAndPersist] as const;
|
||||
};
|
||||
@@ -292,6 +292,30 @@ test("terminal side panel auto-open settings are syncable for auto-sync detectio
|
||||
);
|
||||
});
|
||||
|
||||
test("note appearance settings survive sync and trigger auto-sync", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, "Menlo, monospace");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE, "16");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, "14");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
assert.equal(payload.settings?.noteFontFamily, "Menlo, monospace");
|
||||
assert.equal(payload.settings?.noteFontSize, 16);
|
||||
assert.equal(payload.settings?.noteCodeFontSize, 14);
|
||||
for (const key of [
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
]) {
|
||||
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
|
||||
}
|
||||
|
||||
localStorage.clear();
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY), "Menlo, monospace");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE), "16");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
||||
|
||||
@@ -108,6 +108,9 @@ import {
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
|
||||
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica';
|
||||
@@ -275,6 +278,9 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
@@ -432,6 +438,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (lang) settings.uiLanguage = lang;
|
||||
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
||||
if (css != null) settings.customCSS = css;
|
||||
const noteFontFamily = localStorageAdapter.readString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY);
|
||||
if (noteFontFamily != null) settings.noteFontFamily = noteFontFamily;
|
||||
const noteFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE);
|
||||
if (noteFontSize != null && noteFontSize >= 10 && noteFontSize <= 32) settings.noteFontSize = noteFontSize;
|
||||
const noteCodeFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE);
|
||||
if (noteCodeFontSize != null && noteCodeFontSize >= 10 && noteCodeFontSize <= 32) settings.noteCodeFontSize = noteCodeFontSize;
|
||||
|
||||
// Terminal
|
||||
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
@@ -655,6 +667,13 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
if (settings.noteFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, settings.noteFontFamily);
|
||||
if (settings.noteFontSize != null && settings.noteFontSize >= 10 && settings.noteFontSize <= 32) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE, settings.noteFontSize);
|
||||
}
|
||||
if (settings.noteCodeFontSize != null && settings.noteCodeFontSize >= 10 && settings.noteCodeFontSize <= 32) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, settings.noteCodeFontSize);
|
||||
}
|
||||
|
||||
// Terminal
|
||||
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import "./noteMath.scss";
|
||||
|
||||
export { InlineMarkdownEditor } from "./InlineMarkdownEditor";
|
||||
@@ -1,18 +1,219 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
annotateNoteCodeBlockDeleteButtons,
|
||||
createNoteDecorationMutationScheduler,
|
||||
getHostPickerTriggerRange,
|
||||
getNoteDecorationMutationDelay,
|
||||
getRenderedNoteHeadingText,
|
||||
isNoteSmallImageWidth,
|
||||
isNoteMathLanguageLabel,
|
||||
isSupportedNoteExternalHref,
|
||||
isPointerInsideLinkActionHoverZone,
|
||||
invokeNoteEditorDialogAction,
|
||||
linkActionStatesEqual,
|
||||
NOTE_SMALL_IMAGE_MAX_WIDTH,
|
||||
NOTE_EDIT_DECORATION_DEBOUNCE_MS,
|
||||
resolveHostPickerPopupPosition,
|
||||
scrollNoteHeadingIntoView,
|
||||
shouldApplyExternalNoteMarkdown,
|
||||
shouldRenderNoteMathFormula,
|
||||
shouldInsertClipboardTextAsMarkdown,
|
||||
shouldHandleHostPickerNavigationKey,
|
||||
} from "./InlineMarkdownEditor.tsx";
|
||||
import { getSourceEditDelta, shouldCoalesceSourceUndoStep } from "./NoteSourceEditor.tsx";
|
||||
|
||||
test("source undo history coalesces only adjacent edits of the same typing kind", () => {
|
||||
const previous = { inputType: "insertText", at: 1_000, caret: 2 };
|
||||
const adjacentInsert = getSourceEditDelta("ab", "abc");
|
||||
const movedCaretInsert = getSourceEditDelta("ab", "axb");
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_500, adjacentInsert), true);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_500, movedCaretInsert), false);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_751, adjacentInsert), false);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "deleteContentBackward", 1_100, adjacentInsert), false);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertFromPaste", 1_100, adjacentInsert), false);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(null, "insertText", 1_100, adjacentInsert), false);
|
||||
});
|
||||
|
||||
test("math language detection does not mistake plain text blocks for TeX", () => {
|
||||
assert.equal(isNoteMathLanguageLabel("math"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("Math (LaTeX)"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("language-latex"), true);
|
||||
assert.equal(isNoteMathLanguageLabel("language-tex highlighted"), true);
|
||||
assert.equal(isNoteMathLanguageLabel("latex"), true);
|
||||
assert.equal(isNoteMathLanguageLabel("tex"), true);
|
||||
assert.equal(isNoteMathLanguageLabel("公式"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("text"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("plaintext"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("typescript"), false);
|
||||
assert.equal(shouldRenderNoteMathFormula("Plain text"), false);
|
||||
assert.equal(shouldRenderNoteMathFormula("plaintext"), false);
|
||||
assert.equal(shouldRenderNoteMathFormula(""), false);
|
||||
assert.equal(shouldRenderNoteMathFormula("math"), false);
|
||||
assert.equal(shouldRenderNoteMathFormula("latex"), true);
|
||||
assert.equal(shouldRenderNoteMathFormula("tex"), true);
|
||||
});
|
||||
|
||||
test("live note decoration scans are debounced while preview mounts stay immediate", () => {
|
||||
assert.equal(getNoteDecorationMutationDelay("edit"), NOTE_EDIT_DECORATION_DEBOUNCE_MS);
|
||||
assert.equal(getNoteDecorationMutationDelay("live"), NOTE_EDIT_DECORATION_DEBOUNCE_MS);
|
||||
assert.equal(getNoteDecorationMutationDelay("preview"), 0);
|
||||
|
||||
let nextId = 1;
|
||||
let runCount = 0;
|
||||
const timers = new Map<number, () => void>();
|
||||
const frames = new Map<number, () => void>();
|
||||
const runtime = {
|
||||
requestFrame: (callback: () => void) => {
|
||||
const id = nextId++;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
cancelFrame: (id: number) => { frames.delete(id); },
|
||||
setTimer: (callback: () => void, delay: number) => {
|
||||
assert.equal(delay, NOTE_EDIT_DECORATION_DEBOUNCE_MS);
|
||||
const id = nextId++;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: number) => { timers.delete(id); },
|
||||
};
|
||||
|
||||
const editScheduler = createNoteDecorationMutationScheduler("edit", () => { runCount += 1; }, runtime);
|
||||
editScheduler.schedule();
|
||||
editScheduler.schedule();
|
||||
editScheduler.schedule();
|
||||
assert.equal(runCount, 0);
|
||||
assert.equal(timers.size, 1);
|
||||
const pendingEdit = [...timers.values()][0];
|
||||
timers.clear();
|
||||
pendingEdit();
|
||||
assert.equal(runCount, 1);
|
||||
|
||||
editScheduler.schedule();
|
||||
assert.equal(timers.size, 1);
|
||||
const cancelledEdit = [...timers.values()][0];
|
||||
editScheduler.cancel();
|
||||
assert.equal(timers.size, 0);
|
||||
cancelledEdit();
|
||||
assert.equal(runCount, 1);
|
||||
|
||||
const previewScheduler = createNoteDecorationMutationScheduler("preview", () => { runCount += 1; }, runtime);
|
||||
previewScheduler.schedule();
|
||||
previewScheduler.schedule();
|
||||
assert.equal(frames.size, 1);
|
||||
const pendingPreview = [...frames.values()][0];
|
||||
frames.clear();
|
||||
pendingPreview();
|
||||
assert.equal(runCount, 2);
|
||||
});
|
||||
|
||||
test("note link and image actions open the editor dialogs", () => {
|
||||
const opened: string[] = [];
|
||||
const dialogs = {
|
||||
openImageDialog: () => opened.push("image"),
|
||||
openLinkDialog: () => opened.push("link"),
|
||||
};
|
||||
|
||||
assert.equal(invokeNoteEditorDialogAction("link", dialogs), true);
|
||||
assert.equal(invokeNoteEditorDialogAction("image", dialogs), true);
|
||||
assert.equal(invokeNoteEditorDialogAction("bold", dialogs), false);
|
||||
assert.equal(invokeNoteEditorDialogAction("link", null), false);
|
||||
assert.deepEqual(opened, ["link", "image"]);
|
||||
});
|
||||
|
||||
test("toolbar text selection is restricted to the note editor", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /if \(domText\) \{/);
|
||||
assert.match(source, /domSelection && domSelection\.rangeCount > 0 && container/);
|
||||
assert.match(source, /container\.contains\(range\.startContainer\)/);
|
||||
assert.match(source, /container\.contains\(range\.endContainer\)/);
|
||||
assert.match(source, /return domText;[\s\S]*return "";[\s\S]*querySelector\("\[contenteditable\]"\)/);
|
||||
});
|
||||
|
||||
test("note outline jumps to the matching rendered heading", () => {
|
||||
let scrollOptions: ScrollIntoViewOptions | undefined;
|
||||
const first = {
|
||||
tagName: "H1",
|
||||
textContent: "Quoted",
|
||||
scrollIntoView: () => undefined,
|
||||
} as unknown as HTMLElement;
|
||||
const second = {
|
||||
tagName: "H2",
|
||||
textContent: "Real",
|
||||
scrollIntoView: (options?: ScrollIntoViewOptions) => {
|
||||
scrollOptions = options;
|
||||
},
|
||||
} as unknown as HTMLElement;
|
||||
const root = {
|
||||
querySelectorAll: (selector: string) => {
|
||||
assert.match(selector, /\.netcatty-mdx-content h1/);
|
||||
assert.match(selector, /\.netcatty-mdx-content h6/);
|
||||
return [first, second];
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Real" }), true);
|
||||
assert.deepEqual(scrollOptions, {
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
inline: "nearest",
|
||||
});
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 3, text: "Missing" }), false);
|
||||
assert.equal(scrollNoteHeadingIntoView(null, { level: 1, text: "None" }), false);
|
||||
});
|
||||
|
||||
test("note outline matches rendered whitespace and strikethrough heading text", () => {
|
||||
let scrolled = false;
|
||||
const renderedHeading = {
|
||||
tagName: "H2",
|
||||
textContent: "Removed\n title",
|
||||
scrollIntoView: () => {
|
||||
scrolled = true;
|
||||
},
|
||||
} as unknown as HTMLElement;
|
||||
const root = {
|
||||
querySelectorAll: () => [renderedHeading],
|
||||
};
|
||||
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Removed title" }), true);
|
||||
assert.equal(scrolled, true);
|
||||
});
|
||||
|
||||
test("note outline matches image alt text inside rendered headings", () => {
|
||||
const textNode = (textContent: string) => ({ nodeType: 3, textContent, childNodes: [] });
|
||||
const imageNode = (alt: string) => ({
|
||||
nodeType: 1,
|
||||
tagName: "IMG",
|
||||
textContent: "",
|
||||
childNodes: [],
|
||||
getAttribute: (name: string) => name === "alt" ? alt : null,
|
||||
});
|
||||
let scrolled = 0;
|
||||
const imageHeading = {
|
||||
tagName: "H2",
|
||||
textContent: "",
|
||||
childNodes: [imageNode("Logo")],
|
||||
scrollIntoView: () => { scrolled += 1; },
|
||||
} as unknown as HTMLElement;
|
||||
const mixedHeading = {
|
||||
tagName: "H2",
|
||||
textContent: "Release notes",
|
||||
childNodes: [textNode("Release "), imageNode("Logo"), textNode(" notes")],
|
||||
scrollIntoView: () => { scrolled += 1; },
|
||||
} as unknown as HTMLElement;
|
||||
const root = { querySelectorAll: () => [imageHeading, mixedHeading] };
|
||||
|
||||
assert.equal(getRenderedNoteHeadingText(imageHeading), "Logo");
|
||||
assert.equal(getRenderedNoteHeadingText(mixedHeading), "Release Logo notes");
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Logo" }), true);
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Release Logo notes" }), true);
|
||||
assert.equal(scrolled, 2);
|
||||
});
|
||||
|
||||
test("host picker navigation keys are handled even before a query is typed", () => {
|
||||
assert.equal(shouldHandleHostPickerNavigationKey(true, "ArrowDown", 3), true);
|
||||
@@ -74,6 +275,35 @@ test("small note image width threshold matches README badge sizes", () => {
|
||||
assert.equal(isNoteSmallImageWidth(null), false);
|
||||
});
|
||||
|
||||
test("note image actions use a bordered toolbar shown only on hover or focus", () => {
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\][\s\S]*?gap:\s*0\.0625rem;[\s\S]*?padding:\s*0\.125rem;[\s\S]*?opacity:\s*0;[\s\S]*?pointer-events:\s*none;[\s\S]*?border:\s*1px solid/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\] button,[\s\S]*?width:\s*1\.375rem;[\s\S]*?height:\s*1\.375rem;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\] button svg,[\s\S]*?width:\s*0\.875rem;[\s\S]*?height:\s*0\.875rem;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\]:hover \[class\*="_editImageToolbar_"\][\s\S]*?opacity:\s*1;[\s\S]*?pointer-events:\s*auto;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\]:focus-within \[class\*="_editImageToolbar_"\]/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\]\[data-note-img-size="sm"\] \[class\*="_editImageToolbar_"\]/,
|
||||
);
|
||||
});
|
||||
|
||||
test("host picker trigger range only covers the typed trigger and query", () => {
|
||||
const text = "before\n\n@10.2.0.32";
|
||||
const range = getHostPickerTriggerRange(text);
|
||||
@@ -144,37 +374,43 @@ test("note editor registers a code block editor for pasted fenced code", () => {
|
||||
test("note editor enables image plugin for remote markdown images", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /imagePlugin\(\{\s*allowSetImageDimensions:\s*true/);
|
||||
assert.match(source, /InsertImage/);
|
||||
});
|
||||
|
||||
test("note editor exposes preview and edit modes with a markdown toolbar in edit mode", () => {
|
||||
test("note editor exposes its modes from a borderless title-row dropdown", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
const toolbarSource = readFileSync(new URL("./NoteToolbar.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /type NoteEditorMode = "edit" \| "preview"/);
|
||||
assert.match(source, /toolbarPlugin\(\{\s*toolbarContents:/s);
|
||||
assert.match(source, /type NoteEditorMode/);
|
||||
// The app-owned NoteToolbar (not MDXEditor's toolbarPlugin) hosts the
|
||||
// formatting controls; MDXEditor must not render its own toolbar.
|
||||
assert.doesNotMatch(source, /toolbarPlugin\(/);
|
||||
assert.match(toolbarSource, /onAction\?\.\("undo"\)/);
|
||||
assert.match(toolbarSource, /onAction\?\.\("redo"\)/);
|
||||
assert.match(toolbarSource, /<Undo2 size=\{14\} \/>/);
|
||||
assert.match(toolbarSource, /<Redo2 size=\{14\} \/>/);
|
||||
// Preview and edit both use MDXEditor (readOnly in preview).
|
||||
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
|
||||
assert.match(source, /key=\{editorMode\}/);
|
||||
assert.match(source, /netcatty-mdx-editor--preview/);
|
||||
assert.doesNotMatch(source, /NoteMarkdownPreview|react-markdown|github-markdown/);
|
||||
assert.match(source, /<BlockTypeSelect \/>/);
|
||||
assert.match(source, /<BoldItalicUnderlineToggles /);
|
||||
assert.match(source, /<ListsToggle /);
|
||||
assert.match(source, /<InsertCodeBlock \/>/);
|
||||
assert.match(source, /<InsertTable \/>/);
|
||||
assert.match(source, /editorMode = controlledEditorMode \?\? "edit"/);
|
||||
assert.doesNotMatch(source, /data-note-mode-switch/);
|
||||
assert.doesNotMatch(source, /absolute -top-9/);
|
||||
assert.match(managerSource, /data-note-title-row/);
|
||||
assert.match(managerSource, /data-note-mode-switch/);
|
||||
assert.match(managerSource, /Glasses/);
|
||||
assert.match(managerSource, /PencilLine/);
|
||||
assert.match(managerSource, /setNoteEditorMode\(\(currentMode\) => \(/);
|
||||
assert.match(managerSource, /currentMode === "edit" \? "preview" : "edit"/);
|
||||
assert.match(toolbarSource, /data-note-mode-dropdown-trigger/);
|
||||
assert.match(toolbarSource, /data-note-mode-option=\{option\.mode\}/);
|
||||
assert.match(toolbarSource, /gap-1\.5 border-0 bg-transparent px-2/);
|
||||
assert.match(toolbarSource, /<SelectContent align="end" className="w-max min-w-\[10rem\]">/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/data-note-mode-option=\{option\.mode\}[\s\S]*?className="h-9 whitespace-nowrap"/,
|
||||
);
|
||||
assert.match(toolbarSource, /<Select value=\{normalizedMode\}/);
|
||||
assert.doesNotMatch(toolbarSource, /data-note-mode-switch=/);
|
||||
assert.match(managerSource, /data-note-title-row[\s\S]*?<NoteModeDropdown[\s\S]*?<NoteToolbar/);
|
||||
assert.match(managerSource, /<NoteModeDropdown[\s\S]*?editorMode=\{noteEditorMode\}/);
|
||||
assert.match(managerSource, /editorMode=\{noteEditorMode\}/);
|
||||
assert.match(managerSource, /className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary\/70 hover:text-foreground"/);
|
||||
assert.doesNotMatch(managerSource, /data-note-mode-switch[\s\S]{0,500}border/);
|
||||
assert.doesNotMatch(`${source}\n${managerSource}`, /role="tablist"|role="tab"|renderModeButton/);
|
||||
assert.doesNotMatch(`${source}\n${managerSource}`, /className="mb-2 flex shrink-0 items-center justify-end"/);
|
||||
});
|
||||
@@ -182,6 +418,7 @@ test("note editor exposes preview and edit modes with a markdown toolbar in edit
|
||||
test("note markdown toolbar remains usable in narrow panes", () => {
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const toolbarSource = readFileSync(new URL("./NoteToolbar.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /MoreHorizontal|data-note-toolbar-more|netcatty-note-toolbar-more/);
|
||||
|
||||
@@ -195,29 +432,23 @@ test("note markdown toolbar remains usable in narrow panes", () => {
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s*\{[^}]*transform:\s*translateZ\(0\);/s,
|
||||
);
|
||||
// The app-owned NoteToolbar scrolls horizontally when the pane is narrow so
|
||||
// formatting buttons are never clipped; the scrollbar stays visible.
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-note-markdown-toolbar\s*\{[^}]*max-width:\s*100%;[^}]*height:\s*auto\s*!important;[^}]*overflow:\s*visible\s*!important;/s,
|
||||
toolbarSource,
|
||||
/overflow-x-auto [^"]*\[scrollbar-width:thin\]/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-note-markdown-toolbar\s*\{[^}]*box-sizing:\s*border-box;[^}]*display:\s*flex\s*!important;[^}]*flex-wrap:\s*wrap\s*!important;[^}]*align-content:\s*flex-start\s*!important;/s,
|
||||
toolbarSource,
|
||||
/\[&::-webkit-scrollbar\]:h-1\.5/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-note-markdown-toolbar\s*>\s*\*\s*\{[^}]*flex-shrink:\s*0;/s,
|
||||
toolbarSource,
|
||||
/\[&::-webkit-scrollbar-thumb\]:bg-border\/70/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s*\{[^}]*gap:\s*0\.125rem\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s*\{[^}]*flex-wrap:\s*wrap\s*!important;[^}]*align-content:\s*flex-start\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s+button,[\s\S]*\.netcatty-note-markdown-toolbar\s+\[role="button"\][\s\S]*height:\s*1\.75rem\s*!important;/s,
|
||||
toolbarSource,
|
||||
/flex flex-1 items-center gap-0\.5 min-w-0 overflow-x-auto/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -291,15 +522,23 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*padding:\s*0\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*static\s*!important;[^}]*padding:\s*0\.125rem;/s,
|
||||
/\.netcatty-mdx-content\s+pre\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;[^}]*padding:\s*0;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-note-code-copy\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*box-shadow:\s*none\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*absolute\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
@@ -307,7 +546,11 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*fit-content\s*!important;/s,
|
||||
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\] \[class\*="_tooltipTrigger_"\]\s*\{[^}]*display:\s*inline-flex\s*!important;[^}]*align-items:\s*center\s*!important;[^}]*align-self:\s*center\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*0\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
@@ -331,7 +574,7 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.3rem\s+0\s+0\.65rem;/s,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.25rem\s+0\s+0\.55rem;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
@@ -339,7 +582,7 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*padding:\s*0\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
@@ -347,6 +590,29 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("note formulas render without framed surfaces", () => {
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(styles, /data-language="math"/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;[^}]*border:\s*0;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-math-reading-mode\s*\{[^}]*background:\s*transparent\s*!important;[^}]*border:\s*none\s*!important;[^}]*padding:\s*0\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-math-reading-mode\s+\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;/s,
|
||||
);
|
||||
assert.match(styles, /\.netcatty-math-formula-preview\s*\{[^}]*justify-content:\s*safe center;[^}]*overflow-x:\s*auto;/s);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-math-reading-mode\s*>\s*\.netcatty-note-code-copy\s*\{[^}]*display:\s*none\s*!important;/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("getCodeMirrorBlockText reads rendered code block lines", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -364,6 +630,62 @@ test("annotateNoteCodeBlockCopyButtons adds a copy action to code blocks", () =>
|
||||
assert.match(source, /onCopy\(text\)/);
|
||||
});
|
||||
|
||||
test("annotateNoteCodeBlockCopyButtons treats a CSS-hidden toolbar as absent so preview shows the copy button", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /getComputedStyle\(toolbar\)\.display !== "none"/);
|
||||
assert.match(source, /toolbarVisible/);
|
||||
assert.match(source, /firstButton\.parentElement !== wrapper/);
|
||||
assert.match(source, /wrapper\.appendChild\(firstButton\)/);
|
||||
});
|
||||
|
||||
test("annotateNoteCodeBlockDeleteButtons swaps the delete icon only once", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const dom = new JSDOM(`
|
||||
<div id="container">
|
||||
<div class="_codeMirrorToolbar_test">
|
||||
<button class="_toolbarCodeBlockLanguageSelectTrigger_test"></button>
|
||||
<button id="delete"></button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, "HTMLElement");
|
||||
Object.defineProperty(globalThis, "HTMLElement", {
|
||||
configurable: true,
|
||||
value: dom.window.HTMLElement,
|
||||
});
|
||||
|
||||
try {
|
||||
const container = dom.window.document.querySelector("#container") as HTMLElement;
|
||||
const deleteButton = dom.window.document.querySelector("#delete") as HTMLButtonElement;
|
||||
annotateNoteCodeBlockDeleteButtons(container);
|
||||
const installedIcon = deleteButton.firstElementChild;
|
||||
assert.equal(deleteButton.dataset.noteCodeDelete, "true");
|
||||
assert.equal(installedIcon?.nodeName.toLowerCase(), "svg");
|
||||
const observer = new dom.window.MutationObserver(() => {});
|
||||
observer.observe(deleteButton, { attributes: true, childList: true, subtree: true });
|
||||
|
||||
annotateNoteCodeBlockDeleteButtons(container);
|
||||
|
||||
assert.equal(observer.takeRecords().length, 0);
|
||||
assert.equal(deleteButton.firstElementChild, installedIcon);
|
||||
observer.disconnect();
|
||||
} finally {
|
||||
if (previousHTMLElement) {
|
||||
Object.defineProperty(globalThis, "HTMLElement", previousHTMLElement);
|
||||
} else {
|
||||
delete (globalThis as { HTMLElement?: unknown }).HTMLElement;
|
||||
}
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
assert.match(source, /export const annotateNoteCodeBlockDeleteButtons/);
|
||||
assert.match(source, /data-note-code-delete/);
|
||||
assert.match(source, /DELETE_ICON_SVG/);
|
||||
assert.match(source, /stroke="currentColor"/);
|
||||
assert.match(source, /stroke-width="2"/);
|
||||
});
|
||||
|
||||
test("note preview uses MDX readOnly with code-copy chrome", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
@@ -385,18 +707,73 @@ test("note preview uses MDX readOnly with code-copy chrome", () => {
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*static\s*!important;[^}]*justify-content:\s*flex-end;/s,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*justify-content:\s*flex-end;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*font-size:\s*11px\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*font-size:\s*11px\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+button\s*\{[^}]*height:\s*1\.35rem\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s+button[^{]*\{[^}]*height:\s*1\.4rem\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+button\s+svg\s*\{[^}]*width:\s*14px\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\][^{]*svg\s*\{[^}]*width:\s*10px\s*!important;/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("annotateMathFormulaBlocks handles empty math blocks and avoids reading toolbar text", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /export const annotateMathFormulaBlocks/);
|
||||
assert.match(source, /const text = getCodeMirrorBlockText\(wrapper\)\.trim\(\);/);
|
||||
assert.match(source, /if \(!formulaSource\) \{/);
|
||||
assert.match(source, /existingPreview\.remove\(\)/);
|
||||
});
|
||||
|
||||
test("NoteSourceEditor manages local draft state to prevent cursor jumping on debounced commits", () => {
|
||||
const source = readFileSync(new URL("./NoteSourceEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /const \[localValue, setLocalValue\] = useState\(value\);/);
|
||||
assert.match(source, /value=\{localValue\}/);
|
||||
assert.match(source, /onChange=\{handleChange\}/);
|
||||
assert.match(source, /prevNoteIdRef\.current/);
|
||||
assert.match(source, /prevValueRef\.current/);
|
||||
});
|
||||
|
||||
test("source mode compares raw markdown separately from display-normalized markdown", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /const latestSourceMarkdownRef = useRef\(value\)/);
|
||||
assert.match(source, /markdown === latestSourceMarkdownRef\.current/);
|
||||
assert.match(source, /latestMarkdownRef\.current = normalizeNotePublicAssetPaths\(markdown\)/);
|
||||
assert.match(source, /setAcceptedSourceMarkdown\(markdown\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/<NoteSourceEditor[\s\S]*?value=\{noteId !== undefined && noteId !== noteIdRef\.current \? value : acceptedSourceMarkdown\}[\s\S]*?onChange=\{commitSourceMarkdown\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("raw source drafts are not overwritten by external values with equivalent display markdown", () => {
|
||||
const base = {
|
||||
latestMarkdown: "",
|
||||
syncedMarkdown: "",
|
||||
latestSourceMarkdown: "",
|
||||
syncedSourceMarkdown: "",
|
||||
};
|
||||
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
nextSourceMarkdown: "# Remote",
|
||||
}), false);
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
nextSourceMarkdown: "",
|
||||
}), true);
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
latestSourceMarkdown: "",
|
||||
nextSourceMarkdown: "",
|
||||
}), true);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
Copy,
|
||||
FileText,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
type VaultNote,
|
||||
} from "../../domain/notes";
|
||||
import { copyToClipboard } from "../keychain/utils";
|
||||
import { toast } from "../ui/toast";
|
||||
|
||||
export interface NoteExportMenuProps {
|
||||
note: VaultNote | null;
|
||||
allNotes: VaultNote[];
|
||||
onExportNote: (note: VaultNote) => void;
|
||||
onExportAll: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NoteExportMenu: React.FC<NoteExportMenuProps> = ({
|
||||
note,
|
||||
allNotes,
|
||||
onExportNote,
|
||||
onExportAll,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeAndRestoreFocus = useCallback(() => {
|
||||
setOpen(false);
|
||||
requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeAndRestoreFocus();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const items = Array.from(
|
||||
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
|
||||
);
|
||||
if (!items.length) return;
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let next = current;
|
||||
if (e.key === "ArrowDown") next = (current + 1 + items.length) % items.length;
|
||||
else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else if (e.key === "End") next = items.length - 1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
items[next]?.focus();
|
||||
};
|
||||
window.addEventListener("mousedown", handleClickOutside);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
requestAnimationFrame(() => {
|
||||
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleClickOutside);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [closeAndRestoreFocus, open]);
|
||||
|
||||
const handleExportSingleMarkdown = () => {
|
||||
if (!note) return;
|
||||
onExportNote(note);
|
||||
closeAndRestoreFocus();
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
if (!note) return;
|
||||
const ok = await copyToClipboard(note.content);
|
||||
if (ok) {
|
||||
toast.success(t("common.copied") || "已复制到剪贴板");
|
||||
}
|
||||
closeAndRestoreFocus();
|
||||
};
|
||||
|
||||
const handleExportAllZip = () => {
|
||||
if (!allNotes.length) return;
|
||||
onExportAll();
|
||||
closeAndRestoreFocus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block ${className}`} ref={menuRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("notes.export.share")}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div role="menu" className="absolute right-0 top-full mt-1.5 w-56 bg-popover border border-border rounded-lg shadow-lg py-1.5 z-50 text-sm animate-in fade-in-50 zoom-in-95">
|
||||
{note && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{t("notes.export.currentNote")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleExportSingleMarkdown}
|
||||
>
|
||||
<FileText size={14} className="text-primary" />
|
||||
<span>{t("notes.export.exportMarkdown")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleCopyMarkdown}
|
||||
>
|
||||
<Copy size={14} className="text-muted-foreground" />
|
||||
<span>{t("notes.export.copyMarkdown")}</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{t("notes.export.allNotes")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleExportAllZip}
|
||||
disabled={!allNotes.length}
|
||||
>
|
||||
<FileText size={14} className="text-emerald-500" />
|
||||
<span>{t("notes.export.exportAllZip")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import { NoteOutline } from "./NoteOutline.tsx";
|
||||
|
||||
test("note outline renders a borderless hierarchy without heading-level labels", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<NoteOutline
|
||||
content={"## 第一节\n\n### 子章节\n\n## 第二节"}
|
||||
onClose={() => undefined}
|
||||
onSelectHeading={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /data-note-outline="true"/);
|
||||
assert.equal(markup.match(/data-note-outline-item=/g)?.length, 3);
|
||||
assert.match(markup, /padding-left:10px/);
|
||||
assert.match(markup, /padding-left:22px/);
|
||||
assert.doesNotMatch(markup, />H[1-6]</);
|
||||
assert.doesNotMatch(markup, /border-l|border-b|uppercase|tracking-wider/);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ListTree, X } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { extractNoteHeadings, type NoteHeadingItem } from "../../domain/notes";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
|
||||
export interface NoteOutlineProps {
|
||||
content: string;
|
||||
onSelectHeading?: (heading: NoteHeadingItem, index: number) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NoteOutline: React.FC<NoteOutlineProps> = ({
|
||||
content,
|
||||
onSelectHeading,
|
||||
onClose,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const headings = useMemo(() => extractNoteHeadings(content), [content]);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
const minimumHeadingLevel = useMemo(
|
||||
() => headings.length > 0
|
||||
? Math.min(...headings.map((heading) => heading.level))
|
||||
: 1,
|
||||
[headings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHeadingId(null);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("notes.outline.title")}
|
||||
data-note-outline="true"
|
||||
className={`flex h-full flex-col bg-background select-none ${className}`}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between px-3.5 pb-2 pt-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground/90">
|
||||
<ListTree size={14} className="shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{t("notes.outline.title")}</span>
|
||||
<span className="shrink-0 text-[11px] font-normal tabular-nums text-muted-foreground/60">
|
||||
{headings.length}
|
||||
</span>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||
{headings.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
<p>{t("notes.outline.empty")}</p>
|
||||
<p className="mt-1 text-[11px] opacity-70">
|
||||
{t("notes.outline.emptyHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
headings.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-note-outline-item={item.id}
|
||||
data-heading-level={item.level}
|
||||
className={`flex w-full items-center rounded-md py-1.5 pr-2 text-left text-xs leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 ${
|
||||
activeHeadingId === item.id
|
||||
? "bg-secondary/60 font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary/40 hover:text-foreground"
|
||||
}`}
|
||||
style={{ paddingLeft: `${10 + Math.min(item.level - minimumHeadingLevel, 4) * 12}px` }}
|
||||
onClick={() => {
|
||||
setActiveHeadingId(item.id);
|
||||
onSelectHeading?.(item, index);
|
||||
}}
|
||||
title={item.text}
|
||||
>
|
||||
<span className="truncate">{item.text}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
NoteSourceEditor,
|
||||
type NoteSourceEditorHandle,
|
||||
} from "./NoteSourceEditor.tsx";
|
||||
|
||||
test("NoteSourceEditor toolbar undo restores coalesced ordinary typing", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
actEnvironment.requestAnimationFrame = (callback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const editorRef = React.createRef<NoteSourceEditorHandle>();
|
||||
const changes: string[] = [];
|
||||
const textareaNode = {
|
||||
selectionStart: 1,
|
||||
selectionEnd: 1,
|
||||
focus: () => undefined,
|
||||
setSelectionRange(start: number, end: number) {
|
||||
this.selectionStart = start;
|
||||
this.selectionEnd = end;
|
||||
},
|
||||
scrollTop: 0,
|
||||
scrollTo: () => undefined,
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<NoteSourceEditor
|
||||
ref={editorRef}
|
||||
noteId="note-1"
|
||||
value="a"
|
||||
onChange={(nextValue) => changes.push(nextValue)}
|
||||
/>,
|
||||
{
|
||||
createNodeMock: (element) => element.type === "textarea"
|
||||
? textareaNode
|
||||
: { scrollTop: 0 },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = renderer!.root.findByType("textarea");
|
||||
await act(async () => {
|
||||
textarea.props.onChange({
|
||||
target: { value: "ab" },
|
||||
nativeEvent: { inputType: "insertText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.props.onChange({
|
||||
target: { value: "abc" },
|
||||
nativeEvent: { inputType: "insertText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
editorRef.current?.insertAction("undo");
|
||||
});
|
||||
|
||||
let prevented = false;
|
||||
await act(async () => {
|
||||
renderer!.root.findByType("textarea").props.onKeyDown({
|
||||
key: "z",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
preventDefault: () => { prevented = true; },
|
||||
});
|
||||
});
|
||||
const composedTextarea = renderer!.root.findByType("textarea");
|
||||
await act(async () => {
|
||||
composedTextarea.props.onCompositionStart();
|
||||
composedTextarea.props.onChange({
|
||||
target: { value: "abc你", selectionStart: 4 },
|
||||
nativeEvent: { inputType: "insertCompositionText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType("textarea").props.onCompositionEnd();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType("textarea").props.onCompositionStart();
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: "abc你好", selectionStart: 5 },
|
||||
nativeEvent: { inputType: "insertCompositionText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType("textarea").props.onCompositionEnd();
|
||||
});
|
||||
await act(async () => editorRef.current?.insertAction("undo"));
|
||||
await act(async () => editorRef.current?.insertAction("undo"));
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(changes, ["ab", "abc", "a", "abc", "abc你", "abc你好", "abc你", "abc"]);
|
||||
assert.equal(renderer!.root.findByType("textarea").props.value, "abc");
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
actEnvironment.requestAnimationFrame = previousAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
test("NoteSourceEditor keeps formatting and later typing as separate undo steps", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
actEnvironment.requestAnimationFrame = (callback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const editorRef = React.createRef<NoteSourceEditorHandle>();
|
||||
const changes: string[] = [];
|
||||
const textareaNode = {
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
focus: () => undefined,
|
||||
setSelectionRange(start: number, end: number) {
|
||||
this.selectionStart = start;
|
||||
this.selectionEnd = end;
|
||||
},
|
||||
scrollTop: 0,
|
||||
scrollTo: () => undefined,
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<NoteSourceEditor
|
||||
ref={editorRef}
|
||||
noteId="note-1"
|
||||
value=""
|
||||
onChange={(nextValue) => changes.push(nextValue)}
|
||||
/>,
|
||||
{
|
||||
createNodeMock: (element) => element.type === "textarea"
|
||||
? textareaNode
|
||||
: { scrollTop: 0 },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
editorRef.current?.insertAction("bold");
|
||||
});
|
||||
const formatted = "**bold text**";
|
||||
const edited = "**bold text!**";
|
||||
const textarea = renderer!.root.findByType("textarea");
|
||||
await act(async () => {
|
||||
textarea.props.onChange({
|
||||
target: { value: edited },
|
||||
nativeEvent: { inputType: "insertText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
editorRef.current?.insertAction("undo");
|
||||
});
|
||||
await act(async () => {
|
||||
editorRef.current?.insertAction("undo");
|
||||
});
|
||||
|
||||
assert.deepEqual(changes, [formatted, edited, formatted, ""]);
|
||||
assert.equal(renderer!.root.findByType("textarea").props.value, "");
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
actEnvironment.requestAnimationFrame = previousAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
test("NoteSourceEditor undo and redo restore toolbar formatting selections", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
actEnvironment.requestAnimationFrame = (callback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const editorRef = React.createRef<NoteSourceEditorHandle>();
|
||||
const textareaNode = {
|
||||
selectionStart: 0,
|
||||
selectionEnd: 5,
|
||||
focus: () => undefined,
|
||||
setSelectionRange(start: number, end: number) {
|
||||
this.selectionStart = start;
|
||||
this.selectionEnd = end;
|
||||
},
|
||||
scrollTop: 0,
|
||||
scrollTo: () => undefined,
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<NoteSourceEditor
|
||||
ref={editorRef}
|
||||
noteId="note-1"
|
||||
value="hello"
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
{
|
||||
createNodeMock: (element) => element.type === "textarea"
|
||||
? textareaNode
|
||||
: { scrollTop: 0 },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => editorRef.current?.insertAction("bold"));
|
||||
assert.deepEqual(
|
||||
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
|
||||
{ start: 2, end: 7 },
|
||||
);
|
||||
|
||||
await act(async () => editorRef.current?.insertAction("undo"));
|
||||
assert.equal(renderer!.root.findByType("textarea").props.value, "hello");
|
||||
assert.deepEqual(
|
||||
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
|
||||
{ start: 0, end: 5 },
|
||||
);
|
||||
|
||||
await act(async () => editorRef.current?.insertAction("redo"));
|
||||
assert.equal(renderer!.root.findByType("textarea").props.value, "**hello**");
|
||||
assert.deepEqual(
|
||||
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
|
||||
{ start: 2, end: 7 },
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
actEnvironment.requestAnimationFrame = previousAnimationFrame;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import { type MarkdownActionType, wrapMarkdownSyntax } from "../../domain/notes";
|
||||
|
||||
const SOURCE_EDIT_UNDO_COALESCE_MS = 750;
|
||||
|
||||
interface SourceHistorySnapshot {
|
||||
value: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export const shouldCoalesceSourceUndoStep = (
|
||||
previous: { inputType: string; at: number; caret: number } | null,
|
||||
inputType: string,
|
||||
now: number,
|
||||
edit: { start: number; removedLength: number; insertedLength: number },
|
||||
): boolean => {
|
||||
if (!previous || previous.inputType !== inputType || now - previous.at > SOURCE_EDIT_UNDO_COALESCE_MS) {
|
||||
return false;
|
||||
}
|
||||
if (inputType === "insertText") {
|
||||
return edit.removedLength === 0 && edit.start === previous.caret;
|
||||
}
|
||||
if (inputType === "deleteContentBackward") {
|
||||
return edit.insertedLength === 0 && edit.start + edit.removedLength === previous.caret;
|
||||
}
|
||||
if (inputType === "deleteContentForward") {
|
||||
return edit.insertedLength === 0 && edit.start === previous.caret;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getSourceEditDelta = (
|
||||
previousValue: string,
|
||||
nextValue: string,
|
||||
): { start: number; removedLength: number; insertedLength: number } => {
|
||||
let start = 0;
|
||||
const sharedLength = Math.min(previousValue.length, nextValue.length);
|
||||
while (start < sharedLength && previousValue[start] === nextValue[start]) start += 1;
|
||||
|
||||
let previousEnd = previousValue.length;
|
||||
let nextEnd = nextValue.length;
|
||||
while (
|
||||
previousEnd > start
|
||||
&& nextEnd > start
|
||||
&& previousValue[previousEnd - 1] === nextValue[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
removedLength: previousEnd - start,
|
||||
insertedLength: nextEnd - start,
|
||||
};
|
||||
};
|
||||
|
||||
export interface NoteSourceEditorHandle {
|
||||
insertAction: (action: MarkdownActionType) => void;
|
||||
focus: () => void;
|
||||
scrollToLine: (line: number) => boolean;
|
||||
}
|
||||
|
||||
export interface NoteSourceEditorProps {
|
||||
noteId?: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
noteFontFamily?: string;
|
||||
noteFontSize?: number;
|
||||
}
|
||||
|
||||
export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSourceEditorProps>(
|
||||
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize }, ref) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(null);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const prevNoteIdRef = useRef(noteId);
|
||||
const prevValueRef = useRef(value);
|
||||
|
||||
// Undo/redo history for the source textarea (native textarea undo is
|
||||
// unreliable once the value is controlled by React).
|
||||
const undoStackRef = useRef<SourceHistorySnapshot[]>([]);
|
||||
const redoStackRef = useRef<SourceHistorySnapshot[]>([]);
|
||||
const lastUserEditRef = useRef<{ inputType: string; at: number; caret: number } | null>(null);
|
||||
const compositionBaselineRef = useRef<SourceHistorySnapshot | null>(null);
|
||||
const skipNextCompositionCommitRef = useRef(false);
|
||||
|
||||
const resetUserEditCoalescing = () => {
|
||||
lastUserEditRef.current = null;
|
||||
};
|
||||
|
||||
const createSnapshot = (
|
||||
snapshotValue = localValue,
|
||||
selectionStart = textareaRef.current?.selectionStart ?? snapshotValue.length,
|
||||
selectionEnd = textareaRef.current?.selectionEnd ?? selectionStart,
|
||||
): SourceHistorySnapshot => ({
|
||||
value: snapshotValue,
|
||||
selectionStart: Math.min(selectionStart, snapshotValue.length),
|
||||
selectionEnd: Math.min(selectionEnd, snapshotValue.length),
|
||||
});
|
||||
|
||||
// Adopt the external value only for genuine external changes:
|
||||
// - noteId switch → always adopt (new note).
|
||||
// - prop value changed AND differs from localValue → external edit.
|
||||
// The parent debounces our own edits and echoes them back with
|
||||
// value === localValue; those echoes must NOT reset the textarea, or the
|
||||
// keystroke would be reverted and the caret would jump to the end.
|
||||
useEffect(() => {
|
||||
if (noteId !== prevNoteIdRef.current) {
|
||||
prevNoteIdRef.current = noteId;
|
||||
prevValueRef.current = value;
|
||||
setLocalValue(value);
|
||||
undoStackRef.current = [];
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
resetUserEditCoalescing();
|
||||
return;
|
||||
}
|
||||
if (value !== prevValueRef.current) {
|
||||
prevValueRef.current = value;
|
||||
if (value !== localValue) {
|
||||
setLocalValue(value);
|
||||
undoStackRef.current = [];
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
}
|
||||
}, [noteId, value, localValue]);
|
||||
|
||||
const lineCount = (localValue.match(/\n/g)?.length || 0) + 1;
|
||||
const lineNumbers = useMemo(
|
||||
() => Array.from({ length: lineCount }, (_, i) => i + 1).join("\n"),
|
||||
[lineCount],
|
||||
);
|
||||
const gutterWidth = Math.max(48, String(lineCount).length * 9 + 24);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (textareaRef.current && lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const nextValue = e.target.value;
|
||||
if (nextValue === localValue) return;
|
||||
const nativeInputType = (e.nativeEvent as InputEvent | undefined)?.inputType;
|
||||
const inputType = typeof nativeInputType === "string" && nativeInputType
|
||||
? nativeInputType
|
||||
: "input";
|
||||
const isCompositionInput = inputType === "insertCompositionText"
|
||||
|| inputType === "insertFromComposition";
|
||||
if (isCompositionInput) {
|
||||
if (inputType === "insertFromComposition" && skipNextCompositionCommitRef.current) {
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
if (compositionBaselineRef.current === null) {
|
||||
const edit = getSourceEditDelta(localValue, nextValue);
|
||||
const baseline = createSnapshot(
|
||||
localValue,
|
||||
edit.start,
|
||||
edit.start + edit.removedLength,
|
||||
);
|
||||
compositionBaselineRef.current = baseline;
|
||||
undoStackRef.current.push(baseline);
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
if (inputType === "insertFromComposition") compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
compositionBaselineRef.current = null;
|
||||
const now = Date.now();
|
||||
const edit = getSourceEditDelta(localValue, nextValue);
|
||||
if (!shouldCoalesceSourceUndoStep(lastUserEditRef.current, inputType, now, edit)) {
|
||||
const previousSelectionStart = inputType === "deleteContentBackward"
|
||||
? edit.start + edit.removedLength
|
||||
: edit.start;
|
||||
undoStackRef.current.push(createSnapshot(
|
||||
localValue,
|
||||
previousSelectionStart,
|
||||
edit.start + edit.removedLength,
|
||||
));
|
||||
}
|
||||
const caret = typeof e.target.selectionStart === "number"
|
||||
? e.target.selectionStart
|
||||
: edit.start + edit.insertedLength;
|
||||
lastUserEditRef.current = { inputType, at: now, caret };
|
||||
redoStackRef.current = [];
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
const applyHistoryAction = (action: "undo" | "redo"): boolean => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return false;
|
||||
const stack = action === "undo" ? undoStackRef.current : redoStackRef.current;
|
||||
const target = stack.pop();
|
||||
if (target === undefined) return false;
|
||||
resetUserEditCoalescing();
|
||||
compositionBaselineRef.current = null;
|
||||
const current = createSnapshot();
|
||||
if (action === "undo") {
|
||||
redoStackRef.current.push(current);
|
||||
} else {
|
||||
undoStackRef.current.push(current);
|
||||
}
|
||||
setLocalValue(target.value);
|
||||
onChange(target.value);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(target.selectionStart, target.selectionEnd);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const commandModifier = e.metaKey || e.ctrlKey;
|
||||
const historyAction = commandModifier && !e.altKey
|
||||
? key === "z"
|
||||
? (e.shiftKey ? "redo" : "undo")
|
||||
: key === "y" && e.ctrlKey
|
||||
? "redo"
|
||||
: null
|
||||
: null;
|
||||
if (historyAction) {
|
||||
e.preventDefault();
|
||||
applyHistoryAction(historyAction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(e.key)) {
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
|
||||
// Handle Tab insertion
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const nextValue = `${localValue.substring(0, start)} ${localValue.substring(end)}`;
|
||||
undoStackRef.current.push(createSnapshot(localValue, start, end));
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = start + 2;
|
||||
textarea.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertAction: (action: MarkdownActionType) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
if (action === "undo" || action === "redo") {
|
||||
applyHistoryAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const result = wrapMarkdownSyntax(localValue, start, end, action);
|
||||
if (result.text !== localValue) {
|
||||
undoStackRef.current.push(createSnapshot(localValue, start, end));
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
setLocalValue(result.text);
|
||||
onChange(result.text);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(result.selectionStart, result.selectionEnd);
|
||||
});
|
||||
},
|
||||
focus: () => {
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
scrollToLine: (line: number) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return false;
|
||||
const top = Math.max(0, (Math.max(1, line) - 1) * 24 - 12);
|
||||
if (typeof textarea.scrollTo === "function") {
|
||||
textarea.scrollTo({ top, behavior: "smooth" });
|
||||
} else {
|
||||
textarea.scrollTop = top;
|
||||
}
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = top;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-full w-full bg-background font-mono text-sm select-text overflow-hidden ${className}`}
|
||||
>
|
||||
{/* Line numbers gutter */}
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
style={{ width: `${gutterWidth}px` }}
|
||||
className="shrink-0 py-3 select-none text-right pr-3 text-muted-foreground/40 border-r border-border/50 overflow-hidden font-mono text-sm leading-6"
|
||||
onWheel={(e) => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.scrollTop += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<pre className="m-0 whitespace-pre font-inherit leading-6">{lineNumbers}</pre>
|
||||
</div>
|
||||
|
||||
{/* Source Textarea */}
|
||||
<div className="relative flex-1 h-full min-w-0">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={resetUserEditCoalescing}
|
||||
onCompositionStart={() => {
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
if (compositionBaselineRef.current === null) {
|
||||
const baseline = createSnapshot();
|
||||
compositionBaselineRef.current = baseline;
|
||||
undoStackRef.current.push(baseline);
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
resetUserEditCoalescing();
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
const baseline = compositionBaselineRef.current;
|
||||
if (baseline !== null && baseline.value === localValue) {
|
||||
undoStackRef.current.pop();
|
||||
}
|
||||
skipNextCompositionCommitRef.current = baseline !== null && baseline.value !== localValue;
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
fontFamily: noteFontFamily || undefined,
|
||||
fontSize: noteFontSize ? `${noteFontSize}px` : undefined,
|
||||
}}
|
||||
className="w-full h-full py-3 px-4 bg-transparent text-foreground resize-none outline-none font-mono text-sm leading-6 whitespace-pre overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NoteSourceEditor.displayName = "NoteSourceEditor";
|
||||
@@ -0,0 +1,523 @@
|
||||
import {
|
||||
Bold,
|
||||
Check,
|
||||
CheckSquare,
|
||||
Code,
|
||||
Eye,
|
||||
FileCode,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading,
|
||||
Image as ImageIcon,
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
PencilLine,
|
||||
Quote,
|
||||
Redo2,
|
||||
Search,
|
||||
Sigma,
|
||||
SquareCode,
|
||||
Strikethrough,
|
||||
Table as TableIcon,
|
||||
Type,
|
||||
Underline,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { type MarkdownActionType } from "../../domain/notes";
|
||||
import { useAvailableFonts } from "../../application/state/fontStore";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import type { ActiveTextFormats, NoteEditorMode } from "./noteEditorTypes";
|
||||
import { EMPTY_ACTIVE_FORMATS } from "./noteEditorTypes";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "../ui/select";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface NoteToolbarProps {
|
||||
editorMode: NoteEditorMode;
|
||||
onAction?: (action: MarkdownActionType) => void;
|
||||
onOpenHostPicker?: () => void;
|
||||
className?: string;
|
||||
noteFontFamily?: string;
|
||||
onChangeNoteFontFamily?: (font: string) => void;
|
||||
noteFontSize?: number;
|
||||
onChangeNoteFontSize?: (size: number) => void;
|
||||
noteCodeFontSize?: number;
|
||||
onChangeNoteCodeFontSize?: (size: number) => void;
|
||||
/** Active text-format toggles at the current selection (button highlight). */
|
||||
activeFormats?: ActiveTextFormats;
|
||||
}
|
||||
|
||||
export interface NoteModeDropdownProps {
|
||||
editorMode: NoteEditorMode;
|
||||
onChangeMode: (mode: NoteEditorMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FONT_SIZES = [12, 13, 14, 15, 16, 18, 20];
|
||||
const CODE_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18];
|
||||
|
||||
export const NoteModeDropdown: React.FC<NoteModeDropdownProps> = ({
|
||||
editorMode,
|
||||
onChangeMode,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const normalizedMode: "edit" | "source" | "preview" =
|
||||
editorMode === "live" ? "edit" : editorMode;
|
||||
const options = [
|
||||
{
|
||||
mode: "edit" as const,
|
||||
label: t("notes.toolbar.modeLive"),
|
||||
title: t("notes.toolbar.modeLiveTitle"),
|
||||
icon: PencilLine,
|
||||
},
|
||||
{
|
||||
mode: "source" as const,
|
||||
label: t("notes.toolbar.modeSource"),
|
||||
title: t("notes.toolbar.modeSourceTitle"),
|
||||
icon: SquareCode,
|
||||
},
|
||||
{
|
||||
mode: "preview" as const,
|
||||
label: t("notes.toolbar.modePreview"),
|
||||
title: t("notes.toolbar.modePreviewTitle"),
|
||||
icon: Eye,
|
||||
},
|
||||
];
|
||||
const activeOption = options.find((option) => option.mode === normalizedMode) ?? options[0];
|
||||
const ActiveIcon = activeOption.icon;
|
||||
|
||||
return (
|
||||
<Select value={normalizedMode} onValueChange={(mode) => onChangeMode(mode as NoteEditorMode)}>
|
||||
<SelectTrigger
|
||||
data-note-mode-dropdown-trigger
|
||||
aria-label={activeOption.title}
|
||||
className={cn(
|
||||
"app-no-drag h-8 w-auto shrink-0 gap-1.5 border-0 bg-transparent px-2 text-xs text-muted-foreground shadow-none hover:bg-secondary/70 hover:text-foreground focus:ring-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ActiveIcon size={15} />
|
||||
<span>{activeOption.label}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="w-max min-w-[10rem]">
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<SelectItem
|
||||
key={option.mode}
|
||||
value={option.mode}
|
||||
data-note-mode-option={option.mode}
|
||||
className="h-9 whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap"><Icon size={14} />{option.label}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoteToolbar: React.FC<NoteToolbarProps> = ({
|
||||
editorMode,
|
||||
onAction,
|
||||
className = "",
|
||||
noteFontFamily = "",
|
||||
onChangeNoteFontFamily,
|
||||
noteFontSize = 14,
|
||||
onChangeNoteFontSize,
|
||||
noteCodeFontSize = 13,
|
||||
onChangeNoteCodeFontSize,
|
||||
activeFormats = EMPTY_ACTIVE_FORMATS,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [fontSearch, setFontSearch] = useState("");
|
||||
|
||||
// The font tool only controls code block / inline code fonts, so it lists
|
||||
// system monospace fonts (fontStore) rather than the UI font set.
|
||||
const availableSystemFonts = useAvailableFonts();
|
||||
|
||||
const systemFontList = useMemo(() => {
|
||||
const defaultOption = { label: t("notes.toolbar.defaultFont"), value: "" };
|
||||
const list = availableSystemFonts.map((f) => ({
|
||||
label: f.name,
|
||||
value: f.family,
|
||||
}));
|
||||
return [defaultOption, ...list];
|
||||
}, [availableSystemFonts, t]);
|
||||
|
||||
const filteredFonts = useMemo(() => {
|
||||
if (!fontSearch.trim()) return systemFontList;
|
||||
const query = fontSearch.trim().toLowerCase();
|
||||
return systemFontList.filter(
|
||||
(f) => f.label.toLowerCase().includes(query) || f.value.toLowerCase().includes(query),
|
||||
);
|
||||
}, [fontSearch, systemFontList]);
|
||||
|
||||
const isEditing = editorMode === "edit" || editorMode === "live" || editorMode === "source";
|
||||
|
||||
if (!isEditing) return null;
|
||||
|
||||
// Highlight style for toggles that are active at the current selection.
|
||||
const formatButtonClass = (active: boolean) =>
|
||||
cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
active
|
||||
? "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 border-b border-border/70 bg-card/40 text-xs select-none min-w-0 ${className}`}
|
||||
>
|
||||
{/* Formatting Tools (Available in Live Preview & Source Mode) */}
|
||||
<div className="flex flex-1 items-center gap-0.5 min-w-0 overflow-x-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/70 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
|
||||
{/* Undo / Redo */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("undo")}
|
||||
title={t("notes.toolbar.undo")}
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("redo")}
|
||||
title={t("notes.toolbar.redo")}
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-border mx-1 shrink-0" />
|
||||
|
||||
{/* Code Font & Typography Settings Dropdown */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
title={t("notes.toolbar.typography")}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Type size={14} />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="start" className="w-64 p-2.5 space-y-2.5 z-50 text-xs shadow-lg">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[11px] font-medium text-muted-foreground">
|
||||
<span>{t("notes.toolbar.customCodeFont")}</span>
|
||||
<span className="text-[10px] opacity-70">
|
||||
{t("notes.toolbar.fontsAvailable", { count: systemFontList.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative flex items-center">
|
||||
<Search size={12} className="absolute left-2 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("notes.toolbar.searchFont")}
|
||||
value={fontSearch}
|
||||
onChange={(e) => setFontSearch(e.target.value)}
|
||||
className="w-full pl-6 pr-2 py-1 rounded border border-border bg-background text-[11px] text-foreground outline-none focus:border-primary placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Font List */}
|
||||
<div className="space-y-0.5 max-h-52 overflow-y-auto pr-1">
|
||||
{filteredFonts.length === 0 ? (
|
||||
<div className="py-2 text-center text-muted-foreground text-[11px]">
|
||||
{t("notes.toolbar.noFontsFound")}
|
||||
</div>
|
||||
) : (
|
||||
filteredFonts.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
style={{ fontFamily: f.value || undefined }}
|
||||
className={cn(
|
||||
"w-full px-2 py-1 rounded text-left text-xs transition-colors flex items-center justify-between gap-1.5",
|
||||
(noteFontFamily || "") === f.value
|
||||
? "bg-primary text-primary-foreground font-medium"
|
||||
: "hover:bg-secondary text-foreground",
|
||||
)}
|
||||
onClick={() => onChangeNoteFontFamily?.(f.value)}
|
||||
>
|
||||
<span className="truncate">{f.label}</span>
|
||||
{(noteFontFamily || "") === f.value && <Check size={12} className="shrink-0" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<div className="text-[11px] font-medium text-muted-foreground mb-1">{t("notes.toolbar.bodyFontSize")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{FONT_SIZES.map((sz) => (
|
||||
<button
|
||||
key={sz}
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs border transition-colors",
|
||||
(noteFontSize || 14) === sz
|
||||
? "bg-primary text-primary-foreground border-primary font-medium"
|
||||
: "border-border hover:bg-secondary text-foreground",
|
||||
)}
|
||||
onClick={() => onChangeNoteFontSize?.(sz)}
|
||||
>
|
||||
{sz}px
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<div className="text-[11px] font-medium text-muted-foreground mb-1">{t("notes.toolbar.codeFontSize")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{CODE_FONT_SIZES.map((sz) => (
|
||||
<button
|
||||
key={sz}
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs border transition-colors font-mono",
|
||||
(noteCodeFontSize || 13) === sz
|
||||
? "bg-primary text-primary-foreground border-primary font-medium"
|
||||
: "border-border hover:bg-secondary text-foreground",
|
||||
)}
|
||||
onClick={() => onChangeNoteCodeFontSize?.(sz)}
|
||||
>
|
||||
{sz}px
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
{/* Heading Dropdown (Using Portal Dropdown to avoid clipping) */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={t("notes.toolbar.headingLevel")}
|
||||
>
|
||||
<Heading size={14} />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="start" className="w-32 py-1 z-50 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("h1")}
|
||||
>
|
||||
<Heading1 size={14} className="text-primary" />
|
||||
<span>{t("notes.toolbar.h1")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("h2")}
|
||||
>
|
||||
<Heading2 size={14} className="text-primary" />
|
||||
<span>{t("notes.toolbar.h2")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("h3")}
|
||||
>
|
||||
<Heading3 size={14} className="text-primary" />
|
||||
<span>{t("notes.toolbar.h3")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("h4")}
|
||||
>
|
||||
<Heading4 size={14} className="text-primary" />
|
||||
<span>{t("notes.toolbar.h4")}</span>
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
{/* Inline Styles */}
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.bold)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("bold")}
|
||||
title={t("notes.toolbar.bold")}
|
||||
>
|
||||
<Bold size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.italic)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("italic")}
|
||||
title={t("notes.toolbar.italic")}
|
||||
>
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.strikethrough)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("strikethrough")}
|
||||
title={t("notes.toolbar.strikethrough")}
|
||||
>
|
||||
<Strikethrough size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.underline)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("underline")}
|
||||
title={t("notes.toolbar.underline")}
|
||||
>
|
||||
<Underline size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.code)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("code")}
|
||||
title={t("notes.toolbar.inlineCode")}
|
||||
>
|
||||
<Code size={14} />
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-border mx-1 shrink-0" />
|
||||
|
||||
{/* Lists */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("bullet")}
|
||||
title={t("notes.toolbar.bulletList")}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("number")}
|
||||
title={t("notes.toolbar.orderedList")}
|
||||
>
|
||||
<ListOrdered size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("task")}
|
||||
title={t("notes.toolbar.taskList")}
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-border mx-1 shrink-0" />
|
||||
|
||||
{/* Blocks */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("quote")}
|
||||
title={t("notes.toolbar.quote")}
|
||||
>
|
||||
<Quote size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("codeblock")}
|
||||
title={t("notes.toolbar.codeBlock")}
|
||||
>
|
||||
<FileCode size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("math")}
|
||||
title={t("notes.toolbar.mathFormula")}
|
||||
>
|
||||
<Sigma size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("table")}
|
||||
title={t("notes.toolbar.table")}
|
||||
>
|
||||
<TableIcon size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("divider")}
|
||||
title={t("notes.toolbar.divider")}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("link")}
|
||||
title={t("notes.toolbar.link")}
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("image")}
|
||||
title={t("notes.toolbar.image")}
|
||||
>
|
||||
<ImageIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -180,7 +180,11 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/latestMarkdownRef\.current !== syncedPropValueRef\.current/,
|
||||
/shouldApplyExternalNoteMarkdown/,
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedSourceMarkdownRef/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import { readStoredStringValue } from "../../application/state/useStoredString.ts";
|
||||
import { STORAGE_KEY_VAULT_NOTES_EDITOR_MODE } from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { VaultNote } from "../../types.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
import {
|
||||
@@ -17,6 +20,8 @@ import {
|
||||
getValidatedNoteSelectionState,
|
||||
getSelectedVaultNote,
|
||||
isNoteFolderTreeSelected,
|
||||
isNoteEditorMode,
|
||||
normalizeNoteEditorMode,
|
||||
NotesManager,
|
||||
} from "./NotesManager.tsx";
|
||||
|
||||
@@ -82,6 +87,45 @@ test("NotesManager balances folder and note tree icon sizes", () => {
|
||||
|
||||
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-folder/);
|
||||
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
|
||||
assert.match(markup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
|
||||
assert.doesNotMatch(markup, /lucide lucide-file-text[^"]*mr-2/);
|
||||
});
|
||||
|
||||
test("NotesManager gives folder and tag metadata pills the same compact style", () => {
|
||||
const markup = renderNotes([note({ tags: ["inspection"] })]);
|
||||
const document = new JSDOM(markup).window.document;
|
||||
const folderPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="folder"]');
|
||||
const tagPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="tag"]');
|
||||
const addTagPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="add-tag"]');
|
||||
|
||||
assert.ok(folderPill);
|
||||
assert.ok(tagPill);
|
||||
assert.ok(addTagPill);
|
||||
for (const className of [
|
||||
"inline-flex",
|
||||
"h-5",
|
||||
"items-center",
|
||||
"gap-1",
|
||||
"rounded-md",
|
||||
"bg-muted/70",
|
||||
"px-2",
|
||||
"text-[11px]",
|
||||
"font-medium",
|
||||
"leading-none",
|
||||
]) {
|
||||
assert.ok(folderPill.classList.contains(className), `folder pill should include ${className}`);
|
||||
assert.ok(tagPill.classList.contains(className), `tag pill should include ${className}`);
|
||||
assert.ok(addTagPill.classList.contains(className), `add-tag pill should include ${className}`);
|
||||
}
|
||||
assert.ok(tagPill.classList.contains("text-foreground"));
|
||||
assert.ok(addTagPill.classList.contains("text-foreground"));
|
||||
assert.equal(tagPill.classList.contains("border"), false);
|
||||
assert.equal(tagPill.classList.contains("bg-primary/10"), false);
|
||||
assert.equal(folderPill.querySelector("svg")?.classList.contains("-translate-y-px"), false);
|
||||
assert.equal(tagPill.querySelector("svg")?.classList.contains("-translate-y-px"), false);
|
||||
assert.ok([...folderPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
|
||||
assert.ok([...tagPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
|
||||
assert.ok([...addTagPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
|
||||
});
|
||||
|
||||
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
|
||||
@@ -114,6 +158,36 @@ test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
|
||||
assert.equal(clampNotesTreeWidth(900), 520);
|
||||
});
|
||||
|
||||
test("normalizeNoteEditorMode migrates the legacy live mode to edit", () => {
|
||||
assert.equal(normalizeNoteEditorMode("live"), "edit");
|
||||
assert.equal(isNoteEditorMode("live"), false);
|
||||
assert.equal(isNoteEditorMode("edit"), true);
|
||||
assert.equal(normalizeNoteEditorMode("preview"), "preview");
|
||||
assert.equal(normalizeNoteEditorMode("invalid"), null);
|
||||
});
|
||||
|
||||
test("stored legacy live mode falls back to edit on the real read path", (t) => {
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => key === STORAGE_KEY_VAULT_NOTES_EDITOR_MODE ? "live" : null,
|
||||
} as Storage,
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
readStoredStringValue(STORAGE_KEY_VAULT_NOTES_EDITOR_MODE, "edit", isNoteEditorMode),
|
||||
"edit",
|
||||
);
|
||||
});
|
||||
|
||||
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
|
||||
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
|
||||
const noteSelection = getNoteSelectionState(notes[0], false);
|
||||
@@ -351,4 +425,6 @@ test("NotesManager can re-open the same sidebar note when the request id changes
|
||||
|
||||
assert.match(source, /openNoteRequestId\?: number \| null/);
|
||||
assert.match(source, /\[isSidebarMode, onOpenNoteIdHandled, openNoteId, openNoteRequestId, sortedNotes\]/);
|
||||
assert.match(source, /if \(isSidebarMode && overlayNoteView\)/);
|
||||
assert.match(source, /\[isSidebarMode, overlayNoteView\]/);
|
||||
});
|
||||
+407
-102
@@ -6,12 +6,13 @@ import {
|
||||
FileText,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Glasses,
|
||||
Hash,
|
||||
ListTree,
|
||||
MoreHorizontal,
|
||||
Minimize2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -20,6 +21,12 @@ import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
||||
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
||||
import { useStoredString } from "../../application/state/useStoredString";
|
||||
import { useAvailableFonts } from "../../application/state/fontStore";
|
||||
import { resolveNoteFontFamily } from "../../domain/noteFonts";
|
||||
import { NoteOutline } from "./NoteOutline";
|
||||
import { NoteExportMenu } from "./NoteExportMenu";
|
||||
import { NoteModeDropdown, NoteToolbar } from "./NoteToolbar";
|
||||
import type { NoteSourceEditorHandle } from "./NoteSourceEditor";
|
||||
import {
|
||||
ancestorNoteGroupPaths,
|
||||
buildVaultNoteMarkdownExportFiles,
|
||||
@@ -35,11 +42,16 @@ import {
|
||||
replaceNoteGroupPrefix,
|
||||
resolveMovedNoteGroupPath,
|
||||
sanitizeNoteExportFileNamePart,
|
||||
type MarkdownActionType,
|
||||
type NoteHeadingItem,
|
||||
type VaultNotesExportScope,
|
||||
} from "../../domain/notes";
|
||||
import { getNextVaultOrder, reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { logger } from "../../lib/logger";
|
||||
@@ -76,16 +88,21 @@ import {
|
||||
markVaultDropIndicator,
|
||||
markVaultInsideDropIndicator,
|
||||
} from "../vault/vaultReorderDrag";
|
||||
import type { NoteEditorMode } from "./InlineMarkdownEditor";
|
||||
import type {
|
||||
ActiveTextFormats,
|
||||
InlineMarkdownEditorHandle,
|
||||
NoteEditorMode,
|
||||
} from "./noteEditorTypes";
|
||||
import { EMPTY_ACTIVE_FORMATS } from "./noteEditorTypes";
|
||||
import { NoteTitleInput } from "./NoteTitleInput";
|
||||
|
||||
const InlineMarkdownEditor = lazy(() =>
|
||||
import("./InlineMarkdownEditor").then((module) => ({ default: module.InlineMarkdownEditor })),
|
||||
import("./InlineMarkdownEditor.lazy").then((module) => ({ default: module.InlineMarkdownEditor })),
|
||||
);
|
||||
|
||||
/** Warm the MDXEditor chunk before Suspense. */
|
||||
export function prefetchInlineMarkdownEditor(): void {
|
||||
void import("./InlineMarkdownEditor");
|
||||
void import("./InlineMarkdownEditor.lazy");
|
||||
}
|
||||
|
||||
interface NoteFolderNode {
|
||||
@@ -99,6 +116,8 @@ type NotesToolbarPanel = "search" | null;
|
||||
|
||||
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
|
||||
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
|
||||
const noteMetadataPillClass = "inline-flex h-5 items-center gap-1 rounded-md bg-muted/70 px-2 text-[11px] font-medium leading-none";
|
||||
const noteMetadataLabelClass = "translate-y-px";
|
||||
const NOTES_TREE_DEFAULT_WIDTH = 300;
|
||||
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
|
||||
const NOTES_TREE_MIN_WIDTH = 160;
|
||||
@@ -111,10 +130,14 @@ export function clampNotesTreeWidth(value: number): number {
|
||||
}
|
||||
|
||||
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
|
||||
value === "edit" || value === "preview" ? value : null;
|
||||
value === "live"
|
||||
? "edit"
|
||||
: value === "edit" || value === "preview" || value === "source"
|
||||
? value
|
||||
: null;
|
||||
|
||||
const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
|
||||
normalizeNoteEditorMode(value) !== null;
|
||||
export const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
|
||||
value === "edit" || value === "preview" || value === "source";
|
||||
|
||||
const InlineMarkdownEditorFallback = () => (
|
||||
<div
|
||||
@@ -372,6 +395,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
"edit",
|
||||
isNoteEditorMode,
|
||||
);
|
||||
const [activeFormats, setActiveFormats] = useState<ActiveTextFormats>(EMPTY_ACTIVE_FORMATS);
|
||||
const [overlayNoteId, setOverlayNoteId] = useState<string | null>(() => initialOpenNoteId);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
() => new Set(notes.flatMap((note) => note.group ? ancestorNoteGroupPaths(note.group) : [])),
|
||||
@@ -388,11 +412,41 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
const [tagInputOpen, setTagInputOpen] = useState(false);
|
||||
const [newTagText, setNewTagText] = useState("");
|
||||
const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
|
||||
NOTES_TREE_DEFAULT_WIDTH,
|
||||
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
|
||||
);
|
||||
const [noteFontFamily, setNoteFontFamily] = useStoredString<string>(
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
"",
|
||||
);
|
||||
const availableNoteFonts = useAvailableFonts();
|
||||
const resolvedNoteFontFamily = useMemo(
|
||||
() => resolveNoteFontFamily(availableNoteFonts, noteFontFamily),
|
||||
[availableNoteFonts, noteFontFamily],
|
||||
);
|
||||
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
14,
|
||||
{ min: 10, max: 32 },
|
||||
);
|
||||
const handleSetNoteFontSize = useCallback((size: number) => {
|
||||
setNoteFontSize(size);
|
||||
persistNoteFontSize(size);
|
||||
}, [persistNoteFontSize, setNoteFontSize]);
|
||||
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
13,
|
||||
{ min: 10, max: 32 },
|
||||
);
|
||||
const handleSetNoteCodeFontSize = useCallback((size: number) => {
|
||||
setNoteCodeFontSize(size);
|
||||
persistNoteCodeFontSize(size);
|
||||
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
|
||||
const treeAsideRef = useRef<HTMLElement | null>(null);
|
||||
const treeWidthRef = useRef(treeWidth);
|
||||
treeWidthRef.current = treeWidth;
|
||||
@@ -418,6 +472,26 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return cleaned;
|
||||
}, [onUpdateNotes]);
|
||||
|
||||
const addTagToNote = useCallback((noteId: string, tag: string) => {
|
||||
const clean = tag.trim();
|
||||
if (!clean) return;
|
||||
commitNotes(sortedNotesRef.current.map((n) => {
|
||||
if (n.id !== noteId) return n;
|
||||
const existing = n.tags ?? [];
|
||||
if (existing.includes(clean)) return n;
|
||||
return { ...n, tags: [...existing, clean], updatedAt: Date.now() };
|
||||
}));
|
||||
}, [commitNotes]);
|
||||
|
||||
const removeTagFromNote = useCallback((noteId: string, tagToRemove: string) => {
|
||||
commitNotes(sortedNotesRef.current.map((n) => {
|
||||
if (n.id !== noteId) return n;
|
||||
const existing = n.tags ?? [];
|
||||
const nextTags = existing.filter((t) => t !== tagToRemove);
|
||||
return { ...n, tags: nextTags.length ? nextTags : undefined, updatedAt: Date.now() };
|
||||
}));
|
||||
}, [commitNotes]);
|
||||
|
||||
const NOTE_DRAFT_DEBOUNCE_MS = 300;
|
||||
const draftNoteIdRef = useRef<string | null>(null);
|
||||
const draftTitleRef = useRef<string | null>(null);
|
||||
@@ -523,18 +597,21 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}, [groupOrderByPath, groups, sortedNotes]);
|
||||
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
|
||||
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
|
||||
const selectedNoteView = selectedNote && draftNoteId === selectedNote.id
|
||||
? {
|
||||
...selectedNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
}
|
||||
: selectedNote;
|
||||
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
|
||||
? {
|
||||
...overlayNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
}
|
||||
: overlayNote;
|
||||
const selectedNoteView = useMemo(() => {
|
||||
if (!selectedNote) return null;
|
||||
if (draftNoteId === selectedNote.id && draftTitle !== null) {
|
||||
return { ...selectedNote, title: draftTitle };
|
||||
}
|
||||
return selectedNote;
|
||||
}, [draftNoteId, draftTitle, selectedNote]);
|
||||
|
||||
const overlayNoteView = useMemo(() => {
|
||||
if (!overlayNote) return null;
|
||||
if (draftNoteId === overlayNote.id && draftTitle !== null) {
|
||||
return { ...overlayNote, title: draftTitle };
|
||||
}
|
||||
return overlayNote;
|
||||
}, [draftNoteId, draftTitle, overlayNote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftNoteIdRef.current) return;
|
||||
@@ -667,34 +744,21 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onOpenHost?.(host, { noteId });
|
||||
}, [onOpenHost]);
|
||||
|
||||
const renderNoteModeToggle = () => {
|
||||
const label = noteEditorMode === "edit" ? t("notes.mode.preview") : t("notes.mode.edit");
|
||||
const Icon = noteEditorMode === "edit" ? Glasses : PencilLine;
|
||||
const sourceEditorRef = useRef<NoteSourceEditorHandle>(null);
|
||||
const inlineEditorRef = useRef<InlineMarkdownEditorHandle>(null);
|
||||
const overlayEditorRef = useRef<InlineMarkdownEditorHandle>(null);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-note-mode-switch
|
||||
aria-label={label}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
||||
onClick={() => {
|
||||
flushNoteDraft();
|
||||
setNoteEditorMode((currentMode) => (
|
||||
currentMode === "edit" ? "preview" : "edit"
|
||||
));
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
const handleToolbarAction = useCallback((action: MarkdownActionType) => {
|
||||
if (isSidebarMode && overlayNoteView) {
|
||||
overlayEditorRef.current?.executeAction(action);
|
||||
} else {
|
||||
inlineEditorRef.current?.executeAction(action);
|
||||
}
|
||||
}, [isSidebarMode, overlayNoteView]);
|
||||
|
||||
const handleOutlineHeadingSelect = useCallback((heading: NoteHeadingItem, index: number) => {
|
||||
inlineEditorRef.current?.scrollToHeading(heading, index);
|
||||
}, []);
|
||||
|
||||
const addNoteToGroup = (group: string | null) => {
|
||||
let created: VaultNote | null = null;
|
||||
@@ -861,24 +925,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
exportNotesToZip({ type: "group", group: groupPath }, groupPath);
|
||||
}, [exportNotesToZip]);
|
||||
|
||||
const renderNoteExportButton = (note: VaultNote) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("notes.action.exportNote")}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
||||
onClick={() => exportNoteToMarkdown(note)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("notes.action.exportNote")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const duplicateNoteById = (noteId: string) => {
|
||||
flushNoteDraft();
|
||||
const source = sortedNotesRef.current.find((note) => note.id === noteId);
|
||||
@@ -1244,7 +1290,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
style={{ paddingLeft: depth * 16 + 4 }}
|
||||
>
|
||||
<div className="mr-1 h-5 w-4 shrink-0" />
|
||||
<div className="mr-2 flex h-5 w-5 shrink-0 items-center justify-center text-current">
|
||||
<div className="mr-1 flex shrink-0 items-center text-current">
|
||||
<Folder size={16} strokeWidth={1.9} />
|
||||
</div>
|
||||
<VaultTreeInlineRenameInput
|
||||
@@ -1274,7 +1320,8 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
renameNoteFromTree(note.id, name);
|
||||
}}
|
||||
onRenameCancel={() => setEditingNoteId(null)}
|
||||
icon={<FileText size={16} className="mr-2 shrink-0 text-muted-foreground" />}
|
||||
icon={<FileText size={16} className="shrink-0 text-muted-foreground" />}
|
||||
iconClassName="mr-1"
|
||||
data-note-id={note.id}
|
||||
data-notes-drag-kind="note"
|
||||
data-notes-context-menu="note"
|
||||
@@ -1351,6 +1398,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
selected={isNoteFolderTreeSelected(selectedGroup, selectedNoteId, node.path)}
|
||||
hasChildren={hasChildren}
|
||||
iconSize={16}
|
||||
iconClassName="mr-1"
|
||||
editing={editingGroupPath === node.path}
|
||||
editingInitialName={node.name}
|
||||
onRenameCommit={(name) => renameGroup(node.path, name)}
|
||||
@@ -1742,7 +1790,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
>
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-8 pt-6 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<NoteTitleInput
|
||||
noteId={selectedNoteView.id}
|
||||
@@ -1754,35 +1802,226 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
{renderNoteExportButton(selectedNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="min-h-full w-full px-8 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
|
||||
<NoteModeDropdown
|
||||
editorMode={noteEditorMode}
|
||||
onChangeMode={(mode) => {
|
||||
flushNoteDraft();
|
||||
setNoteEditorMode(mode);
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
/>
|
||||
|
||||
{/* Outline Toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("notes.outline.title")}
|
||||
className={cn(
|
||||
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
|
||||
showOutline ? "bg-secondary text-primary font-medium" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setShowOutline((prev) => !prev)}
|
||||
>
|
||||
<ListTree size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("notes.outline.title")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Export Menu */}
|
||||
<NoteExportMenu
|
||||
note={selectedNoteView}
|
||||
allNotes={sortedNotes}
|
||||
onExportNote={exportNoteToMarkdown}
|
||||
onExportAll={exportAllNotes}
|
||||
/>
|
||||
|
||||
{/* Delete Note */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("action.delete")}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => requestDeleteNoteById(selectedNoteView.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("action.delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Tags & Folder Breadcrumb Header */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-8 pb-2 text-xs text-muted-foreground">
|
||||
{selectedNoteView.group && (
|
||||
<span
|
||||
data-note-metadata-pill="folder"
|
||||
className={cn(noteMetadataPillClass, "text-foreground")}
|
||||
>
|
||||
<Folder size={12} className="text-amber-500" />
|
||||
<span className={noteMetadataLabelClass}>{selectedNoteView.group}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{selectedNoteView.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
data-note-metadata-pill="tag"
|
||||
className={cn(noteMetadataPillClass, "text-foreground")}
|
||||
>
|
||||
<Hash size={12} className="opacity-70" />
|
||||
<span className={noteMetadataLabelClass}>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
|
||||
onClick={() => removeTagFromNote(selectedNoteView.id, tag)}
|
||||
title={t("notes.tag.remove")}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{tagInputOpen ? (
|
||||
<div className="inline-flex h-5 items-center gap-1 rounded-md border border-primary/60 bg-background px-1.5 leading-none">
|
||||
<Hash size={12} className="text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newTagText}
|
||||
onChange={(e) => setNewTagText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (newTagText.trim()) {
|
||||
addTagToNote(selectedNoteView.id, newTagText.trim());
|
||||
setNewTagText("");
|
||||
setTagInputOpen(false);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setTagInputOpen(false);
|
||||
setNewTagText("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (newTagText.trim()) {
|
||||
addTagToNote(selectedNoteView.id, newTagText.trim());
|
||||
}
|
||||
setTagInputOpen(false);
|
||||
setNewTagText("");
|
||||
}}
|
||||
placeholder={t("notes.tag.placeholder")}
|
||||
className="w-20 translate-y-px bg-transparent text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-note-metadata-pill="add-tag"
|
||||
className={cn(noteMetadataPillClass, "text-foreground transition-colors hover:bg-muted")}
|
||||
onClick={() => setTagInputOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span className={noteMetadataLabelClass}>{t("notes.tag.add")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cherry Studio Style Note Toolbar */}
|
||||
<NoteToolbar
|
||||
editorMode={noteEditorMode}
|
||||
onAction={handleToolbarAction}
|
||||
activeFormats={activeFormats}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
onChangeNoteFontFamily={(font) => setNoteFontFamily(font)}
|
||||
noteFontSize={noteFontSize}
|
||||
onChangeNoteFontSize={handleSetNoteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onChangeNoteCodeFontSize={handleSetNoteCodeFontSize}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 min-h-0 min-w-0">
|
||||
{noteEditorMode === "source" ? (
|
||||
<div
|
||||
className="flex-1 min-h-0 min-w-0 h-full"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
ref={inlineEditorRef}
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
sourceEditorRef={sourceEditorRef}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
noteFontSize={noteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onActiveFormatsChange={setActiveFormats}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="min-h-full w-full px-8 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
ref={inlineEditorRef}
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
sourceEditorRef={sourceEditorRef}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
noteFontSize={noteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onActiveFormatsChange={setActiveFormats}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{showOutline && (
|
||||
<div className="h-full w-64 shrink-0">
|
||||
<NoteOutline
|
||||
content={selectedNoteView.content}
|
||||
onSelectHeading={handleOutlineHeadingSelect}
|
||||
onClose={() => setShowOutline(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-4">
|
||||
@@ -1833,7 +2072,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-4 pt-5 pb-1" data-note-title-row>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-4 pt-5 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<NoteTitleInput
|
||||
noteId={overlayNoteView.id}
|
||||
@@ -1845,12 +2084,39 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
{renderNoteExportButton(overlayNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
|
||||
<NoteModeDropdown
|
||||
editorMode={noteEditorMode}
|
||||
onChangeMode={(mode) => {
|
||||
flushNoteDraft();
|
||||
setNoteEditorMode(mode);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NoteExportMenu
|
||||
note={overlayNoteView}
|
||||
allNotes={sortedNotes}
|
||||
onExportNote={exportNoteToMarkdown}
|
||||
onExportAll={exportAllNotes}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
|
||||
{/* Note Toolbar in Overlay */}
|
||||
<NoteToolbar
|
||||
editorMode={noteEditorMode}
|
||||
onAction={handleToolbarAction}
|
||||
activeFormats={activeFormats}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
onChangeNoteFontFamily={(font) => setNoteFontFamily(font)}
|
||||
noteFontSize={noteFontSize}
|
||||
onChangeNoteFontSize={handleSetNoteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onChangeNoteCodeFontSize={handleSetNoteCodeFontSize}
|
||||
/>
|
||||
|
||||
{noteEditorMode === "source" ? (
|
||||
<div
|
||||
className="min-h-full w-full px-4 pt-2 pb-6"
|
||||
className="flex-1 min-h-0 min-w-0 h-full"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
@@ -1860,6 +2126,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
ref={overlayEditorRef}
|
||||
noteId={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
@@ -1869,11 +2136,49 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
sourceEditorRef={sourceEditorRef}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
noteFontSize={noteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onActiveFormatsChange={setActiveFormats}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="min-h-full w-full px-4 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
ref={overlayEditorRef}
|
||||
noteId={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
onChange={(content) => saveNoteContentDraft(overlayNoteView.id, content)}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
sourceEditorRef={sourceEditorRef}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
noteFontSize={noteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onActiveFormatsChange={setActiveFormats}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MarkdownActionType, NoteHeadingItem } from "../../domain/notes";
|
||||
|
||||
export interface InlineMarkdownEditorHandle {
|
||||
executeAction: (action: MarkdownActionType) => void;
|
||||
focus: () => void;
|
||||
scrollToHeading: (heading: NoteHeadingItem, headingIndex: number) => boolean;
|
||||
}
|
||||
|
||||
export type NoteEditorMode = "edit" | "preview" | "source" | "live";
|
||||
|
||||
/** Active text-format toggles at the current selection (toolbar highlight). */
|
||||
export type ActiveTextFormats = {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikethrough: boolean;
|
||||
code: boolean;
|
||||
};
|
||||
|
||||
export const EMPTY_ACTIVE_FORMATS: ActiveTextFormats = {
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
code: false,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@use "katex/src/styles/katex.scss" with (
|
||||
$font-folder: "katex/dist/fonts",
|
||||
$use-woff: false,
|
||||
$use-ttf: false,
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normalizeNoteMathSource,
|
||||
NOTE_MATH_KATEX_OPTIONS,
|
||||
renderNoteMathFormula,
|
||||
} from "./noteMathRenderer.ts";
|
||||
|
||||
test("note math renderer uses accessible, untrusted KaTeX output", () => {
|
||||
assert.equal(NOTE_MATH_KATEX_OPTIONS.displayMode, true);
|
||||
assert.equal(NOTE_MATH_KATEX_OPTIONS.output, "htmlAndMathml");
|
||||
assert.equal(NOTE_MATH_KATEX_OPTIONS.throwOnError, false);
|
||||
assert.equal(NOTE_MATH_KATEX_OPTIONS.trust, false);
|
||||
|
||||
const html = renderNoteMathFormula(String.raw`E = mc^2`);
|
||||
assert.match(html, /class="katex-display"/);
|
||||
assert.match(html, /class="katex-mathml"/);
|
||||
assert.match(html, /<msup>/);
|
||||
});
|
||||
|
||||
test("note math renderer handles prime shorthand and matrices", () => {
|
||||
const primes = renderNoteMathFormula(String.raw`f'(x) + x''`);
|
||||
assert.match(primes, /<msup>/);
|
||||
assert.match(primes, /′/);
|
||||
|
||||
const matrix = renderNoteMathFormula(String.raw`\begin{pmatrix} a & b \\ c & d \end{pmatrix}^{-1}`);
|
||||
assert.match(matrix, /<mtable(?:\s|>)/);
|
||||
assert.match(matrix, /<msup>/);
|
||||
});
|
||||
|
||||
test("note math renderer supports common LaTeX constructs without custom parsing", () => {
|
||||
const html = renderNoteMathFormula(
|
||||
String.raw`\sum_{i=1}^n \frac{\alpha_i}{\sqrt[3]{x_i}} \times \left\lVert v \right\rVert`,
|
||||
);
|
||||
assert.match(html, /∑/);
|
||||
assert.match(html, /<mfrac>/);
|
||||
assert.match(html, /<mroot>/);
|
||||
assert.match(html, /α/);
|
||||
assert.match(html, /∥/);
|
||||
});
|
||||
|
||||
test("note math renderer accepts balanced outer display delimiters only", () => {
|
||||
assert.equal(normalizeNoteMathSource(" $$ x^2 $$ "), "x^2");
|
||||
assert.equal(normalizeNoteMathSource(String.raw`\[ x^2 \]`), "x^2");
|
||||
assert.equal(normalizeNoteMathSource("$$ x^2"), "$$ x^2");
|
||||
assert.equal(normalizeNoteMathSource("x^2 $$"), "x^2 $$");
|
||||
});
|
||||
|
||||
test("note math renderer shows invalid input without throwing", () => {
|
||||
const html = renderNoteMathFormula(String.raw`\frac{`);
|
||||
assert.match(html, /class="katex-error"/);
|
||||
assert.match(html, /\\frac/);
|
||||
});
|
||||
|
||||
test("note math renderer blocks untrusted links and external images", () => {
|
||||
const link = renderNoteMathFormula(String.raw`\href{javascript:alert(1)}{click}`);
|
||||
assert.doesNotMatch(link, /href=/i);
|
||||
|
||||
const image = renderNoteMathFormula(String.raw`\includegraphics{https://example.com/x.png}`);
|
||||
assert.doesNotMatch(image, /<img/i);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import katex, { type KatexOptions } from "katex";
|
||||
|
||||
export const NOTE_MATH_KATEX_OPTIONS: KatexOptions = Object.freeze({
|
||||
displayMode: true,
|
||||
output: "htmlAndMathml",
|
||||
throwOnError: false,
|
||||
strict: "warn",
|
||||
trust: false,
|
||||
maxExpand: 1_000,
|
||||
maxSize: 20,
|
||||
});
|
||||
|
||||
export const normalizeNoteMathSource = (source: string): string => {
|
||||
const trimmed = source.trim();
|
||||
if (trimmed.length >= 4 && trimmed.startsWith("$$") && trimmed.endsWith("$$")) {
|
||||
return trimmed.slice(2, -2).trim();
|
||||
}
|
||||
if (trimmed.length >= 4 && trimmed.startsWith("\\[") && trimmed.endsWith("\\]")) {
|
||||
return trimmed.slice(2, -2).trim();
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const renderNoteMathFormula = (source: string): string => katex.renderToString(
|
||||
normalizeNoteMathSource(source),
|
||||
NOTE_MATH_KATEX_OPTIONS,
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("terminal font dropdown always preserves an explicit default option", () => {
|
||||
const source = readFileSync(new URL("./TerminalFontSelect.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /font\.id === "" \|\| font\.id === value/);
|
||||
});
|
||||
@@ -51,7 +51,7 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
|
||||
// the version (isFontInstalled reads module state).
|
||||
void availabilityVersion;
|
||||
const filtered = fonts.filter(
|
||||
(font) => font.id === value || isFontInstalled(extractPrimaryFamily(font.family)),
|
||||
(font) => font.id === "" || font.id === value || isFontInstalled(extractPrimaryFamily(font.family)),
|
||||
);
|
||||
if (hasAuthoritativeData()) return filtered;
|
||||
return filtered.length >= 1 ? filtered : fonts;
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import React, { memo, useCallback, useState } from "react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { applyCustomCssToDocument } from "../../../lib/customCss";
|
||||
import { DebouncedTextarea } from "../DebouncedTextarea";
|
||||
import { Check, HelpCircle, Monitor, Moon, Palette, Sun } from "lucide-react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
|
||||
import { useStoredString } from "../../../application/state/useStoredString";
|
||||
import { useStoredNumber } from "../../../application/state/useStoredNumber";
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
|
||||
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
|
||||
import { useAvailableFonts } from "../../../application/state/fontStore";
|
||||
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { APP_ICON_VARIANT_ASSET_PATH, APP_ICON_VARIANT_GROUPS, APP_ICON_VARIANT_I18N_KEY } from "../../../infrastructure/config/appIconVariants";
|
||||
import { STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../infrastructure/config/storageKeys";
|
||||
import {
|
||||
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
} from "../../../infrastructure/config/storageKeys";
|
||||
import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant";
|
||||
import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts";
|
||||
import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -61,11 +71,40 @@ function SettingsAppearanceTab(props: {
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
// Note code fonts come from the monospace-only store (fontStore); the note
|
||||
// body font follows the UI font setting instead.
|
||||
const availableMonoFonts = useAvailableFonts();
|
||||
const noteFontOptions = useMemo(() => [
|
||||
{ id: "", name: t("notes.toolbar.defaultFont"), family: "", description: "", category: "monospace" as const },
|
||||
...availableMonoFonts,
|
||||
], [availableMonoFonts, t]);
|
||||
const [customCssHelpOpen, setCustomCssHelpOpen] = useState(false);
|
||||
const [autoImportSystemKnownHosts, setAutoImportSystemKnownHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
);
|
||||
const [noteFontFamily, setNoteFontFamily] = useStoredString<string>(
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
"",
|
||||
);
|
||||
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
14,
|
||||
{ min: 10, max: 32 },
|
||||
);
|
||||
const handleSetNoteFontSize = useCallback((size: number) => {
|
||||
setNoteFontSize(size);
|
||||
persistNoteFontSize(size);
|
||||
}, [persistNoteFontSize, setNoteFontSize]);
|
||||
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
13,
|
||||
{ min: 10, max: 32 },
|
||||
);
|
||||
const handleSetNoteCodeFontSize = useCallback((size: number) => {
|
||||
setNoteCodeFontSize(size);
|
||||
persistNoteCodeFontSize(size);
|
||||
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
|
||||
const {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
@@ -464,6 +503,59 @@ function SettingsAppearanceTab(props: {
|
||||
onChange={setAutoImportSystemKnownHosts}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-notes-font"
|
||||
label={t('settings.vault.notesFont')}
|
||||
description={t('settings.vault.notesFontDesc')}
|
||||
>
|
||||
<TerminalFontSelect
|
||||
value={resolveNoteFontSelectionId(noteFontOptions, noteFontFamily)}
|
||||
fonts={noteFontOptions}
|
||||
onChange={(v) => setNoteFontFamily(resolveNoteFontSelectionFamily(noteFontOptions, v))}
|
||||
className="w-48"
|
||||
ariaLabel={t('settings.vault.notesFont')}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-notes-font-size"
|
||||
label={t('settings.vault.notesFontSize')}
|
||||
description={t('settings.vault.notesFontSizeDesc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={22}
|
||||
step={1}
|
||||
value={noteFontSize}
|
||||
onChange={(e) => handleSetNoteFontSize(Number(e.target.value))}
|
||||
className="w-28 accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
|
||||
{noteFontSize}px
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-notes-code-font-size"
|
||||
label={t('settings.vault.notesCodeFontSize')}
|
||||
description={t('settings.vault.notesCodeFontSizeDesc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={22}
|
||||
step={1}
|
||||
value={noteCodeFontSize}
|
||||
onChange={(e) => handleSetNoteCodeFontSize(Number(e.target.value))}
|
||||
className="w-28 accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
|
||||
{noteCodeFontSize}px
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SettingsAnchor anchorId="appearance-custom-css">
|
||||
|
||||
@@ -155,12 +155,44 @@ test("announces folder replacement risk and refocuses Merge for each queued conf
|
||||
.split(/\s+/)
|
||||
.map((id) => env.document.getElementById(id)?.textContent ?? "")
|
||||
.join(" ");
|
||||
assert.match(describedText, /A folder with the same name already exists/);
|
||||
assert.match(describedText, /Replace deletes all destination files and sub-folders/);
|
||||
assert.match(describedText, /Merge: keeps destination-only content/);
|
||||
assert.match(describedText, /Replace: deletes destination-only content and cannot be undone/);
|
||||
assert.doesNotMatch(env.document.body.textContent ?? "", /A folder with the same name already exists/);
|
||||
const dialogTitle = env.document.querySelector("[role=dialog] h2");
|
||||
assert.equal(dialogTitle?.textContent, "docs already exists");
|
||||
assert.equal(dialogTitle?.getAttribute("aria-label"), "Folder Conflict: docs already exists");
|
||||
const warning = env.document.getElementById(describedBy.split(/\s+/).at(-1) ?? "");
|
||||
assert.ok(warning, "folder action guidance should render");
|
||||
assert.equal(warning.querySelectorAll("svg").length, 2, "both guidance rows should use matching icons");
|
||||
assert.equal(warning.querySelectorAll("p.text-xs").length, 0, "guidance rows should use the same text size");
|
||||
|
||||
const replaceButton = Array.from(env.document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === "Replace");
|
||||
assert.ok(replaceButton, "folder replacement action should render");
|
||||
const mergeButton = Array.from(env.document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === "Merge");
|
||||
assert.ok(mergeButton, "folder merge action should render");
|
||||
assert.match(mergeButton.className, /bg-primary/);
|
||||
assert.match(mergeButton.className, /border/);
|
||||
assert.doesNotMatch(replaceButton.className, /(^|\s)bg-destructive(?:\s|$)/);
|
||||
assert.match(replaceButton.className, /text-destructive/);
|
||||
assert.match(replaceButton.className, /border/);
|
||||
assert.equal(replaceButton.getAttribute("aria-describedby"), describedBy.split(/\s+/).at(-1));
|
||||
for (const label of ["Stop", "Skip", "Duplicate", "Merge", "Replace"]) {
|
||||
const action = Array.from(env.document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === label);
|
||||
assert.ok(action, `${label} action should render`);
|
||||
assert.match(action.className, /h-9/);
|
||||
assert.match(action.className, /min-w-24/);
|
||||
assert.match(action.className, /border/);
|
||||
}
|
||||
assert.equal(
|
||||
Array.from(env.document.querySelectorAll("button"))
|
||||
.filter((button) => button.className.includes("bg-primary"))
|
||||
.length,
|
||||
1,
|
||||
"Merge should be the only visually primary action",
|
||||
);
|
||||
await dispatchDomEvent(replaceButton, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* SFTP Conflict Resolution Dialog
|
||||
*/
|
||||
|
||||
import { AlertCircle, AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle, GitMerge } from 'lucide-react';
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
|
||||
@@ -145,6 +145,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
const canMerge = currentCanMerge;
|
||||
const canReplace = currentCanReplace;
|
||||
const presentation = getSftpConflictDialogPresentation(conflict);
|
||||
const showConflictDescription = !presentation.showDirectoryReplaceWarning;
|
||||
const describedBy = presentation.showDirectoryReplaceWarning
|
||||
? `${descriptionId} ${directoryWarningId}`
|
||||
: descriptionId;
|
||||
@@ -157,31 +158,28 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
return (
|
||||
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
|
||||
<DialogContent
|
||||
className="gap-5 p-5 sm:max-w-[640px] sm:p-6"
|
||||
className="gap-4 p-5 sm:max-w-[600px] sm:p-6"
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
<DialogHeader className="space-y-2 pr-8">
|
||||
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<DialogHeader className="space-y-1.5 pr-8">
|
||||
<DialogTitle
|
||||
className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-lg leading-tight"
|
||||
aria-label={`${t(presentation.titleKey)}: ${conflict.fileName} ${t('sftp.conflict.alreadyExistsSuffix')}`}
|
||||
>
|
||||
<span className="min-w-0 break-words">{conflict.fileName}</span>
|
||||
{' '}
|
||||
<span className="text-base font-normal text-muted-foreground">
|
||||
{t('sftp.conflict.alreadyExistsSuffix')}
|
||||
</span>
|
||||
{t(presentation.titleKey)}
|
||||
</DialogTitle>
|
||||
<div id={descriptionId}>
|
||||
<DialogDescription className="text-[15px] leading-6">
|
||||
<div id={descriptionId} className={showConflictDescription ? undefined : 'sr-only'}>
|
||||
<DialogDescription className="leading-5">
|
||||
{t(presentation.descriptionKey)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6">
|
||||
<div className="min-w-0 break-words">
|
||||
<span className="font-medium text-foreground">{conflict.fileName}</span>
|
||||
<span className="ml-1 text-muted-foreground">{t('sftp.conflict.alreadyExistsSuffix')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{presentation.showFileMetadata && (
|
||||
<div className="space-y-3">
|
||||
<ConflictFileSummary
|
||||
@@ -204,15 +202,16 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
{presentation.showDirectoryReplaceWarning && (
|
||||
<div
|
||||
id={directoryWarningId}
|
||||
className="space-y-2 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm leading-6"
|
||||
className="space-y-1.5 text-sm leading-5"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<div className="flex items-start gap-2 text-muted-foreground">
|
||||
<GitMerge className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{t('sftp.conflict.folderMergeHint')}</p>
|
||||
</div>
|
||||
<p className="pl-6 font-medium text-destructive">
|
||||
{t('sftp.conflict.folderReplaceWarning')}
|
||||
</p>
|
||||
<div className="flex items-start gap-2 text-sm font-normal text-destructive/90">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{t('sftp.conflict.folderReplaceWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -229,53 +228,61 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:flex-nowrap sm:items-center sm:justify-end sm:space-x-0">
|
||||
<DialogFooter className="flex flex-col-reverse gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between sm:space-x-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction('stop')}
|
||||
className="min-w-24 shrink-0 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
|
||||
className="min-w-24 self-start border-border/70 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{t('sftp.conflict.action.stop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('skip')}
|
||||
className="min-w-24 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={duplicateButtonRef}
|
||||
variant="outline"
|
||||
onClick={() => handleAction('duplicate')}
|
||||
className="min-w-24 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.duplicate')}
|
||||
</Button>
|
||||
{conflict.isDirectory && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
ref={mergeButtonRef}
|
||||
variant={presentation.mergeVariant}
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
autoFocus={presentation.showDirectoryReplaceWarning}
|
||||
className="min-w-24 shrink-0"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction('skip')}
|
||||
className="min-w-24 border-border/70"
|
||||
>
|
||||
{t('sftp.conflict.action.merge')}
|
||||
{t('sftp.conflict.action.skip')}
|
||||
</Button>
|
||||
)}
|
||||
{canReplace && (
|
||||
<Button
|
||||
ref={replaceButtonRef}
|
||||
variant={presentation.replaceVariant}
|
||||
onClick={() => handleAction('replace')}
|
||||
className={presentation.showDirectoryReplaceWarning
|
||||
? 'min-w-28 shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive'
|
||||
: 'min-w-28 shrink-0'}
|
||||
ref={duplicateButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction('duplicate')}
|
||||
className="min-w-24 border-border/70"
|
||||
>
|
||||
{t('sftp.conflict.action.replace')}
|
||||
{t('sftp.conflict.action.duplicate')}
|
||||
</Button>
|
||||
)}
|
||||
{conflict.isDirectory && (
|
||||
<Button
|
||||
ref={mergeButtonRef}
|
||||
variant={presentation.mergeVariant}
|
||||
size="sm"
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
autoFocus={presentation.showDirectoryReplaceWarning}
|
||||
className="min-w-24 border border-primary"
|
||||
>
|
||||
{t('sftp.conflict.action.merge')}
|
||||
</Button>
|
||||
)}
|
||||
{canReplace && (
|
||||
<Button
|
||||
ref={replaceButtonRef}
|
||||
variant={presentation.replaceVariant}
|
||||
size="sm"
|
||||
onClick={() => handleAction('replace')}
|
||||
aria-describedby={presentation.showDirectoryReplaceWarning ? directoryWarningId : undefined}
|
||||
className={presentation.showDirectoryReplaceWarning
|
||||
? 'min-w-24 border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive'
|
||||
: 'min-w-24'}
|
||||
>
|
||||
{t('sftp.conflict.action.replace')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -528,6 +528,442 @@ test("a keyword written on a saturated scrollback is still colored", async () =>
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("Mosh-style full-screen cursor-addressed repaint keeps highlights", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
|
||||
await write(term, "row-1 ERROR\r\nrow-2 ERROR\r\nrow-3 ERROR\r\nrow-4 ERROR");
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED, RED],
|
||||
);
|
||||
|
||||
// Mosh redraws a numbered remote framebuffer with absolute cursor moves.
|
||||
// Once the remote screen fills, an Enter can repaint rows above the local
|
||||
// cursor without emitting newlines or leaving the primary buffer.
|
||||
await write(
|
||||
term,
|
||||
"\x1b[1;1Hrow-2 ERROR\x1b[2;1Hrow-3 ERROR"
|
||||
+ "\x1b[3;1Hrow-4 ERROR\x1b[4;1Hprompt ERROR",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED, RED],
|
||||
);
|
||||
|
||||
// PTY chunking can split a cursor-addressing sequence at any byte.
|
||||
await write(term, "\x1b[1;");
|
||||
await write(
|
||||
term,
|
||||
"1Hsplit ERROR\x1b[2;1Hrow-4 ERROR"
|
||||
+ "\x1b[3;1Hprompt ERROR\x1b[4;1Hdone ER",
|
||||
);
|
||||
await write(term, "ROR\x1b[1;1H");
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED, RED],
|
||||
);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("Mosh repaint highlights survive every transport split point", async () => {
|
||||
const frame = "\x1b[1;1Hnew-1 ERROR\x1b[2;1Hnew-2 ERROR"
|
||||
+ "\x1b[3;1Hnew-3 ERROR\x1b[4;1Hnew-4 ERROR\x1b[1;1H";
|
||||
for (let split = 1; split < frame.length; split += 1) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1 ERROR\r\nold-2 ERROR\r\nold-3 ERROR\r\nold-4 ERROR");
|
||||
await write(term, frame.slice(0, split));
|
||||
await write(term, frame.slice(split));
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED, RED],
|
||||
`split ${split}`,
|
||||
);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("cursor-addressed repaint follows CRLF rows before restoring the cursor", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
|
||||
await write(term, "\x1b[1;1Hnew-1 ERROR\r\nnew-2 ERROR\r\nnew-3 ERROR\x1b[1;1H");
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 3 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED],
|
||||
);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("cursor-addressed repaint keeps pre-scroll rows highlighted", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
|
||||
await write(
|
||||
term,
|
||||
"\x1b[1;1Hscrolled ERROR\r\nline-2\r\nline-3\r\nline-4\r\nline-5\x1b[1;1H",
|
||||
);
|
||||
|
||||
const highlightedRows: number[] = [];
|
||||
for (let y = 0; y < term.buffer.active.length; y += 1) {
|
||||
if (cellRgb(term, y, "ERROR") === RED) highlightedRows.push(y);
|
||||
}
|
||||
assert.deepEqual(highlightedRows, [0]);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("pressured cursor-addressed repaint catches up from the pre-scroll viewport", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
|
||||
noteTerminalOutputPressureData(term, "x".repeat(20_000));
|
||||
await write(
|
||||
term,
|
||||
"\x1b[1;1Hscrolled ERROR\r\nline-2\r\nline-3\r\nline-4\r\nline-5\x1b[1;1H",
|
||||
);
|
||||
await highlighter.whenSettled();
|
||||
|
||||
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("row-sized Mosh repaint writes do not rescan the viewport for every row", async () => {
|
||||
for (const rows of [40, 80]) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, Array.from({ length: rows }, (_, index) => `old-${index} ERROR`).join("\r\n"));
|
||||
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
let refreshes = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
if (refresh) refreshes += 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
|
||||
for (let row = 1; row <= rows; row += 1) {
|
||||
await write(term, `\x1b[${row};1Hnew-${row} ERROR`);
|
||||
}
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], `${rows} rows`);
|
||||
assert.ok(
|
||||
scannedRows <= rows * 2,
|
||||
`${rows} rows should stay linear, scanned ${scannedRows} rows`,
|
||||
);
|
||||
assert.ok(
|
||||
refreshes <= rows + 1,
|
||||
`${rows} rows should refresh at most once per typical write, got ${refreshes}`,
|
||||
);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("cursor-restoring Mosh row writes keep repaint work linear", async () => {
|
||||
for (const rows of [40, 80]) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, Array.from({ length: rows }, (_, index) => `old-${index} ERROR`).join("\r\n"));
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
let refreshes = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
if (refresh) refreshes += 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
|
||||
for (let row = 1; row <= rows; row += 1) {
|
||||
await write(term, `\x1b[${row};1Hnew-${row} ERROR\x1b[1;1H`);
|
||||
}
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], `${rows} rows`);
|
||||
assert.ok(scannedRows <= rows * 2, `${rows} rows scanned ${scannedRows}`);
|
||||
assert.ok(refreshes <= rows * 2, `${rows} rows refreshed ${refreshes} times`);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("CUP fragments survive enabling highlighting between writes", async () => {
|
||||
for (const control of ["\x1b[2;1H", "\x9b2;1H"]) {
|
||||
for (let split = 1; split < control.length; split += 1) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), false);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, control.slice(0, split));
|
||||
highlighter.setRules(rule(), true);
|
||||
await highlighter.whenSettled();
|
||||
await write(term, `${control.slice(split)}target ERROR\x1b[1;1H`);
|
||||
|
||||
assert.equal(cellRgb(term, 1, "ERROR"), RED, `${JSON.stringify(control)} split ${split}`);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("C1 CUP repaints an earlier viewport row", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
|
||||
await write(term, "\x9b2;1Htarget ERROR\x9b5;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 1, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("out-of-range cursor addresses repaint the clamped viewport row", async () => {
|
||||
for (const control of ["\x1b[999;1H", "\x1b[999d", "\x9b999;1H", "\x9b999d"]) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5\x1b[2;1H");
|
||||
|
||||
await write(term, `${control}target ERROR\x1b[2;1H`);
|
||||
|
||||
assert.equal(cellRgb(term, 4, "ERROR"), RED, JSON.stringify(control));
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("cursor-addressed repaint follows VPR and inserted-line row changes", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1 ERROR\r\nold-2 ERROR\r\nold-3 ERROR\r\nold-4 ERROR");
|
||||
|
||||
await write(term, "\x1b[1;1Hfirst ERROR\x1b[2ethird ERROR\x1b[1;1H");
|
||||
await write(term, "\x1b[2;1H\x1b[1Linserted ERROR\x1b[1;1H");
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("C1 cursor traversal recolors every visited row", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
|
||||
await write(term, "\x9b1;1Hfirst ERROR\x9b2ethird ERROR\x9b1;1H");
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("single-byte C1 row moves recolor every visited row", async () => {
|
||||
for (const control of ["\x84", "\x85", "\x8d"]) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
|
||||
await write(term, `\x9b2;1Hfirst ERROR${control}second ERROR\x9b2;1H`);
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], JSON.stringify(control));
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("cursor-addressed auto-wrap scrolling keeps the pre-scroll row highlighted", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 12, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
|
||||
|
||||
await write(term, `\x1b[4;1HERROR ${"x".repeat(24)}\x1b[1;1H`);
|
||||
|
||||
assert.ok(term.buffer.active.baseY > 0, "fixture must scroll through auto-wrap");
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("origin-mode cursor addresses stay safe across writes and recover after disable", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
|
||||
await write(term, "\x1b[2;4r\x1b[?");
|
||||
await write(term, "6h");
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED);
|
||||
|
||||
await write(term, "\x1b[?6l");
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
await write(term, "\x1b[5;1Hnormal ERROR");
|
||||
assert.equal(cellRgb(term, 4, "ERROR"), RED);
|
||||
assert.ok(scannedRows <= 2, `origin-mode disable should restore the narrow path: ${scannedRows}`);
|
||||
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("origin mode is remembered while keyword highlighting is disabled", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), false);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x1b[2;4r\x1b[?6h");
|
||||
|
||||
highlighter.setRules(rule(), true);
|
||||
await highlighter.whenSettled();
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("multi-parameter origin mode is recognized at every transport split", async () => {
|
||||
for (const control of ["\x1b[?6;25h", "\x1b[?25;6h"]) {
|
||||
for (let split = 1; split < control.length; split += 1) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x1b[2;4r");
|
||||
await write(term, control.slice(0, split));
|
||||
await write(term, control.slice(split));
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED, `${JSON.stringify(control)} split ${split}`);
|
||||
await write(term, `${control.slice(0, -1)}l`);
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
await write(term, "\x1b[5;1Hnormal ERROR");
|
||||
assert.ok(scannedRows <= 2, `${JSON.stringify(control)} disable split ${split}: ${scannedRows}`);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("saved origin mode is restored across every cursor-control split", async () => {
|
||||
const controls = [
|
||||
{ save: "\x1b7", restore: "\x1b8", disableHighlightDuringRestore: false },
|
||||
{ save: "\x1b[s", restore: "\x1b[u", disableHighlightDuringRestore: true },
|
||||
];
|
||||
for (const entry of controls) {
|
||||
for (let saveSplit = 1; saveSplit < entry.save.length; saveSplit += 1) {
|
||||
for (let restoreSplit = 1; restoreSplit < entry.restore.length; restoreSplit += 1) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x1b[2;4r\x1b[?6h");
|
||||
await write(term, entry.save.slice(0, saveSplit));
|
||||
await write(term, entry.save.slice(saveSplit));
|
||||
await write(term, "\x1b[?6l");
|
||||
if (entry.disableHighlightDuringRestore) highlighter.setRules(rule(), false);
|
||||
await write(term, entry.restore.slice(0, restoreSplit));
|
||||
await write(term, entry.restore.slice(restoreSplit));
|
||||
if (entry.disableHighlightDuringRestore) {
|
||||
highlighter.setRules(rule(), true);
|
||||
await highlighter.whenSettled();
|
||||
}
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
|
||||
const fixture = `${JSON.stringify(entry)} save ${saveSplit} restore ${restoreSplit}`;
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED, fixture);
|
||||
await write(term, "\x1b[?6l");
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
await write(term, "\x1b[5;1Hnormal ERROR");
|
||||
assert.ok(scannedRows <= 2, `${fixture}: ${scannedRows}`);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("C1 cursor save and restore preserves origin mode", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x9b2;4r\x9b?6;25h\x9bs\x9b?6l\x9bu");
|
||||
|
||||
await write(term, "\x9b2;1Htarget ERROR\x9b1;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("normal-buffer origin mode survives an alternate-screen round trip", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x1b[2;4r\x1b[?6h\x1b[?1049h");
|
||||
await write(term, "\x1b[?6l");
|
||||
await write(term, "\x1b[?1049l");
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("scrolling during a rule change catch-up recolors newly visible rows", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 4, scrollback: 80 });
|
||||
let bypass = true;
|
||||
|
||||
@@ -45,6 +45,11 @@ type LogicalLine = {
|
||||
cellAtStringOffset: Array<{ y: number; x: number }>;
|
||||
};
|
||||
|
||||
type AbsoluteRepaintRange = {
|
||||
rows: number[];
|
||||
mayTraverseRows: boolean;
|
||||
};
|
||||
|
||||
export type KeywordHighlighterOptions = {
|
||||
shouldBypassHighlight?: () => boolean;
|
||||
serializeAddon?: SerializeAddon;
|
||||
@@ -180,6 +185,18 @@ const collectMatches = (
|
||||
|
||||
const yieldToRenderer = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const trailingIncompleteCsi = (controls: string): string => {
|
||||
const escapeCsi = controls.lastIndexOf("\x1b[");
|
||||
const c1Csi = controls.lastIndexOf("\x9b");
|
||||
const csiStart = Math.max(escapeCsi, c1Csi);
|
||||
if (csiStart >= 0) {
|
||||
const suffix = controls.slice(csiStart);
|
||||
const body = suffix.startsWith("\x1b[") ? suffix.slice(2) : suffix.slice(1);
|
||||
if (!/[\x40-\x7e]/.test(body)) return suffix.slice(-32);
|
||||
}
|
||||
return controls.endsWith("\x1b") ? "\x1b" : "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyword highlighting mutates already-parsed cell foregrounds. Writes stay
|
||||
* pristine, so ordinary Enter/output never rebuilds history, and serialize can
|
||||
@@ -217,6 +234,12 @@ export class KeywordHighlighter implements IDisposable {
|
||||
private lastViewportY = 0;
|
||||
private lastBaseY = 0;
|
||||
private hasOutput = false;
|
||||
private absoluteControlTail = "";
|
||||
private absoluteOriginControlTail = "";
|
||||
private absoluteOriginMode: boolean | null = false;
|
||||
private absoluteActiveBuffer: "normal" | "alternate" | null = "normal";
|
||||
private absoluteNormalSavedOriginMode: boolean | null = false;
|
||||
private absoluteAlternateSavedOriginMode: boolean | null = false;
|
||||
|
||||
get pendingPristineBytes(): number {
|
||||
return 0;
|
||||
@@ -357,6 +380,8 @@ export class KeywordHighlighter implements IDisposable {
|
||||
|
||||
private readonly write: XTerm["write"] = (data, callback) => {
|
||||
if (this.disposed) return this.originalWrite(data, callback);
|
||||
const originModeNeedsSafety = this.trackAbsoluteOriginMode(data);
|
||||
const absoluteControls = this.collectAbsoluteControls(data);
|
||||
const startedOnNormal = this.term.buffer.active.type === "normal";
|
||||
if (!startedOnNormal && this.compiledPatterns.length === 0 && !this.hasPendingCatchUp()) {
|
||||
return this.originalWrite(data, callback);
|
||||
@@ -365,11 +390,15 @@ export class KeywordHighlighter implements IDisposable {
|
||||
if (this.compiledPatterns.length === 0 && !this.hasPendingCatchUp() && startedOnNormal) {
|
||||
return this.originalWrite(data, callback);
|
||||
}
|
||||
const startY = this.term.buffer.active.baseY + this.term.buffer.active.cursorY;
|
||||
const startBaseY = this.term.buffer.active.baseY;
|
||||
const startY = startBaseY + this.term.buffer.active.cursorY;
|
||||
const absoluteRepaintRange = absoluteControls !== null
|
||||
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
|
||||
: null;
|
||||
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
|
||||
if (bypass) {
|
||||
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
|
||||
this.markCatchUp(startY);
|
||||
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
return this.originalWrite(data, () => {
|
||||
@@ -423,16 +452,77 @@ export class KeywordHighlighter implements IDisposable {
|
||||
} else {
|
||||
const startRowMutated = startRowTextBefore !== undefined
|
||||
&& active.getLine(writeMarker.line)?.translateToString(false) !== startRowTextBefore;
|
||||
const fromY = skipStartRow && !startRowMutated
|
||||
const ordinaryFromY = skipStartRow && !startRowMutated
|
||||
? Math.min(endY, writeMarker.line + 1)
|
||||
: writeMarker.line;
|
||||
if (this.compiledPatterns.length === 0) {
|
||||
if (this.hasStoredOriginalsInRange(fromY, endY)) {
|
||||
this.markCatchUp(fromY);
|
||||
this.scheduleCatchUp();
|
||||
if (
|
||||
absoluteRepaintRange !== null
|
||||
&& !absoluteRepaintRange.mayTraverseRows
|
||||
&& active.baseY === startBaseY
|
||||
) {
|
||||
// A normal Mosh framebuffer diff sends one absolute row update per
|
||||
// write. Recolor the old cursor row and the addressed rows as
|
||||
// separate ranges so N row-sized writes stay O(N), rather than
|
||||
// rescanning the gaps between them for every write.
|
||||
const repaintRanges = absoluteRepaintRange.rows.map((row) => ({
|
||||
start: startBaseY + row,
|
||||
end: startBaseY + row,
|
||||
}));
|
||||
const includesLine = (line: number) => repaintRanges.some((range) => (
|
||||
line >= range.start && line <= range.end
|
||||
));
|
||||
if (!includesLine(ordinaryFromY)) {
|
||||
repaintRanges.push({ start: ordinaryFromY, end: ordinaryFromY });
|
||||
}
|
||||
if (!includesLine(endY) && endY !== ordinaryFromY) {
|
||||
repaintRanges.push({ start: endY, end: endY });
|
||||
}
|
||||
repaintRanges.sort((left, right) => left.start - right.start);
|
||||
for (let index = repaintRanges.length - 1; index > 0; index -= 1) {
|
||||
const previous = repaintRanges[index - 1];
|
||||
const current = repaintRanges[index];
|
||||
if (current.start > previous.end + 1) continue;
|
||||
previous.end = Math.max(previous.end, current.end);
|
||||
repaintRanges.splice(index, 1);
|
||||
}
|
||||
if (this.compiledPatterns.length === 0) {
|
||||
const catchUpFrom = repaintRanges.reduce<number | null>((earliest, range) => (
|
||||
this.hasStoredOriginalsInRange(range.start, range.end)
|
||||
? Math.min(earliest ?? range.start, range.start)
|
||||
: earliest
|
||||
), null);
|
||||
if (catchUpFrom !== null) {
|
||||
this.markCatchUp(catchUpFrom);
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
} else {
|
||||
for (const range of repaintRanges) {
|
||||
this.recolorRange(range.start, range.end, true, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.recolorRange(fromY, endY, true, true);
|
||||
// An absolute-positioned update that also traverses rows via
|
||||
// CRLF/IND can scroll before restoring its final cursor. Cover the
|
||||
// pre-write and post-write viewports for that uncommon case.
|
||||
const fromY = absoluteRepaintRange === null
|
||||
? ordinaryFromY
|
||||
: Math.min(ordinaryFromY, startBaseY, active.baseY);
|
||||
const toY = absoluteRepaintRange === null
|
||||
? endY
|
||||
: Math.max(
|
||||
ordinaryFromY,
|
||||
endY,
|
||||
startBaseY + this.term.rows - 1,
|
||||
active.baseY + this.term.rows - 1,
|
||||
);
|
||||
if (this.compiledPatterns.length === 0) {
|
||||
if (this.hasStoredOriginalsInRange(fromY, toY)) {
|
||||
this.markCatchUp(fromY);
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
} else {
|
||||
this.recolorRange(fromY, toY, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0)) {
|
||||
@@ -444,10 +534,131 @@ export class KeywordHighlighter implements IDisposable {
|
||||
});
|
||||
};
|
||||
|
||||
private trackAbsoluteOriginMode(data: string | Uint8Array): boolean {
|
||||
if (typeof data !== "string") {
|
||||
this.absoluteOriginControlTail = "";
|
||||
this.absoluteOriginMode = null;
|
||||
this.absoluteActiveBuffer = null;
|
||||
this.absoluteNormalSavedOriginMode = null;
|
||||
this.absoluteAlternateSavedOriginMode = null;
|
||||
return true;
|
||||
}
|
||||
const controls = this.absoluteOriginControlTail + data;
|
||||
this.absoluteOriginControlTail = trailingIncompleteCsi(controls);
|
||||
const saveOriginMode = () => {
|
||||
if (this.absoluteActiveBuffer === "normal") {
|
||||
this.absoluteNormalSavedOriginMode = this.absoluteOriginMode;
|
||||
} else if (this.absoluteActiveBuffer === "alternate") {
|
||||
this.absoluteAlternateSavedOriginMode = this.absoluteOriginMode;
|
||||
} else {
|
||||
this.absoluteNormalSavedOriginMode = null;
|
||||
this.absoluteAlternateSavedOriginMode = null;
|
||||
}
|
||||
};
|
||||
const restoreOriginMode = () => {
|
||||
this.absoluteOriginMode = this.absoluteActiveBuffer === "normal"
|
||||
? this.absoluteNormalSavedOriginMode
|
||||
: this.absoluteActiveBuffer === "alternate"
|
||||
? this.absoluteAlternateSavedOriginMode
|
||||
: null;
|
||||
};
|
||||
const originModeControls = [
|
||||
...controls.matchAll(
|
||||
/\x1bc|\x1b[78]|(?:\x1b\[|\x9b)(?:!p|[\d;]*[su]|\?[\d;]*[hl])/g, // eslint-disable-line no-control-regex
|
||||
),
|
||||
];
|
||||
let originModeNeedsSafety = this.absoluteOriginMode !== false;
|
||||
for (const match of originModeControls) {
|
||||
const control = match[0];
|
||||
if (control === "\x1bc") {
|
||||
this.absoluteOriginMode = false;
|
||||
this.absoluteActiveBuffer = "normal";
|
||||
this.absoluteNormalSavedOriginMode = false;
|
||||
this.absoluteAlternateSavedOriginMode = false;
|
||||
} else if (control === "\x1b[!p" || control === "\x9b!p") {
|
||||
this.absoluteOriginMode = false;
|
||||
} else if (control === "\x1b7" || control.endsWith("s")) {
|
||||
saveOriginMode();
|
||||
} else if (control === "\x1b8" || control.endsWith("u")) {
|
||||
restoreOriginMode();
|
||||
} else {
|
||||
const parameters = /^(?:\x1b\[|\x9b)\?([\d;]*)[hl]$/.exec(control)?.[1] // eslint-disable-line no-control-regex
|
||||
?.split(";")
|
||||
.map((parameter) => Number.parseInt(parameter, 10))
|
||||
?? [];
|
||||
const enabled = control.endsWith("h");
|
||||
for (const parameter of parameters) {
|
||||
if (parameter === 6) {
|
||||
this.absoluteOriginMode = enabled;
|
||||
} else if (parameter === 1048) {
|
||||
if (enabled) saveOriginMode();
|
||||
else restoreOriginMode();
|
||||
} else if (parameter === 1049) {
|
||||
if (enabled) {
|
||||
saveOriginMode();
|
||||
this.absoluteActiveBuffer = "alternate";
|
||||
} else {
|
||||
this.absoluteActiveBuffer = "normal";
|
||||
restoreOriginMode();
|
||||
}
|
||||
} else if (parameter === 47 || parameter === 1047) {
|
||||
this.absoluteActiveBuffer = enabled ? "alternate" : "normal";
|
||||
}
|
||||
}
|
||||
}
|
||||
originModeNeedsSafety ||= this.absoluteOriginMode !== false;
|
||||
}
|
||||
return originModeNeedsSafety;
|
||||
}
|
||||
|
||||
private collectAbsoluteControls(data: string | Uint8Array): string | null {
|
||||
if (typeof data !== "string") {
|
||||
this.absoluteControlTail = "";
|
||||
return null;
|
||||
}
|
||||
const controls = this.absoluteControlTail + data;
|
||||
this.absoluteControlTail = trailingIncompleteCsi(controls);
|
||||
return controls;
|
||||
}
|
||||
|
||||
private resolveAbsoluteRepaintRange(
|
||||
controls: string,
|
||||
originModeNeedsSafety: boolean,
|
||||
): AbsoluteRepaintRange | null {
|
||||
const rows = new Set<number>();
|
||||
const noteRow = (raw: string | undefined) => {
|
||||
const row = Math.min(
|
||||
this.term.rows - 1,
|
||||
Math.max(0, (Number.parseInt(raw || "1", 10) || 1) - 1),
|
||||
);
|
||||
rows.add(row);
|
||||
};
|
||||
// CUP/HVP and VPA address a viewport row directly. Mosh's framebuffer
|
||||
// diff uses these controls to repaint rows above the current cursor once
|
||||
// the remote screen fills, without emitting a newline or changing buffer.
|
||||
const cup = /(?:\x1b\[|\x9b)(\d*)(?:;\d*)?[Hf]/g; // eslint-disable-line no-control-regex
|
||||
const vpa = /(?:\x1b\[|\x9b)(\d*)d/g; // eslint-disable-line no-control-regex
|
||||
for (const match of controls.matchAll(cup)) noteRow(match[1]);
|
||||
for (const match of controls.matchAll(vpa)) noteRow(match[1]);
|
||||
// These controls can visit rows that are not named by CUP/VPA. Keep the
|
||||
// wider safety range only for writes that actually contain such movement.
|
||||
const mayTraverseRows = originModeNeedsSafety
|
||||
|| /[\n\v\f\x84\x85\x8d]|\x1b[DEM8]|(?:\x1b\[|\x9b)[\d;?]*[ABEFIJLMSTehlru]/.test(controls); // eslint-disable-line no-control-regex
|
||||
return rows.size === 0
|
||||
? null
|
||||
: { rows: [...rows].sort((left, right) => left - right), mayTraverseRows };
|
||||
}
|
||||
|
||||
private readonly reset: XTerm["reset"] = () => {
|
||||
this.clearStoredOriginals();
|
||||
this.cancelCatchUp();
|
||||
this.hasOutput = false;
|
||||
this.absoluteControlTail = "";
|
||||
this.absoluteOriginControlTail = "";
|
||||
this.absoluteOriginMode = false;
|
||||
this.absoluteActiveBuffer = "normal";
|
||||
this.absoluteNormalSavedOriginMode = false;
|
||||
this.absoluteAlternateSavedOriginMode = false;
|
||||
return this.originalReset();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
@@ -68,6 +69,20 @@ test("VaultTreeItemRow exposes shared selected item state", () => {
|
||||
assert.match(markup, /Failover checklist/);
|
||||
});
|
||||
|
||||
test("VaultTree rows can tighten the icon-to-label gap per surface", () => {
|
||||
const groupMarkup = renderToStaticMarkup(
|
||||
<VaultTreeGroupRow name="Production" depth={0} iconClassName="mr-1" />,
|
||||
);
|
||||
const itemMarkup = renderToStaticMarkup(
|
||||
<VaultTreeItemRow label="Failover checklist" depth={0} iconClassName="mr-1" />,
|
||||
);
|
||||
|
||||
assert.match(groupMarkup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
|
||||
assert.match(itemMarkup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
|
||||
assert.doesNotMatch(groupMarkup, /<div class="[^"]*mr-2[^"]*flex shrink-0/);
|
||||
assert.doesNotMatch(itemMarkup, /<div class="[^"]*mr-2[^"]*flex shrink-0/);
|
||||
});
|
||||
|
||||
test("VaultTree labels use CJK-safe line-height under truncate", () => {
|
||||
const groupMarkup = renderToStaticMarkup(
|
||||
<VaultTreeGroupRow name="服务器配置" depth={0} count={1} />,
|
||||
@@ -81,6 +96,29 @@ test("VaultTree labels use CJK-safe line-height under truncate", () => {
|
||||
assert.doesNotMatch(groupMarkup, /leading-none/);
|
||||
assert.match(itemMarkup, /leading-5/);
|
||||
assert.doesNotMatch(itemMarkup, /leading-none/);
|
||||
assert.match(groupMarkup, /flex min-w-0 flex-1 items-center/);
|
||||
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5/);
|
||||
assert.match(groupMarkup, /min-w-0 truncate translate-y-px/);
|
||||
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5 translate-y-px/);
|
||||
assert.doesNotMatch(groupMarkup, /-translate-y-px/);
|
||||
assert.doesNotMatch(itemMarkup, /-translate-y-px/);
|
||||
});
|
||||
|
||||
test("VaultTreeItemRow does not clip the inline rename input", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<VaultTreeItemRow
|
||||
label="机器安全检查报告"
|
||||
depth={0}
|
||||
editing={true}
|
||||
onRenameCommit={() => undefined}
|
||||
onRenameCancel={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const document = new JSDOM(markup).window.document;
|
||||
const input = document.querySelector<HTMLInputElement>('[data-vault-tree-inline-edit="true"]');
|
||||
assert.ok(input);
|
||||
assert.equal(input.parentElement?.classList.contains("h-5"), false);
|
||||
});
|
||||
|
||||
test("VaultTree labels expose full title and keep actions from shrinking", () => {
|
||||
@@ -103,7 +141,7 @@ test("VaultTree labels expose full title and keep actions from shrinking", () =>
|
||||
assert.match(groupMarkup, /min-w-0 truncate/);
|
||||
assert.match(groupMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
|
||||
assert.match(itemMarkup, /title="Security Audit Report - 完整版"/);
|
||||
assert.match(itemMarkup, /min-w-0 truncate leading-5/);
|
||||
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5/);
|
||||
assert.match(itemMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
|
||||
});
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ type VaultTreeGroupRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childr
|
||||
labelActions?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
iconSize?: number;
|
||||
iconClassName?: string;
|
||||
meta?: React.ReactNode;
|
||||
rowRef?: React.Ref<HTMLDivElement>;
|
||||
onToggle?: () => void;
|
||||
@@ -122,6 +123,7 @@ export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
|
||||
labelActions,
|
||||
icon,
|
||||
iconSize = 18,
|
||||
iconClassName,
|
||||
meta,
|
||||
rowRef,
|
||||
className,
|
||||
@@ -146,14 +148,14 @@ export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
|
||||
data-expanded={expanded ? "true" : "false"}
|
||||
{...props}
|
||||
>
|
||||
<div className="mr-1 flex h-5 w-4 flex-shrink-0 items-center justify-center text-muted-foreground">
|
||||
<div className="mr-1 flex w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{canExpand && (
|
||||
<div className={cn("transition-transform duration-200", expanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-2 flex h-5 w-5 shrink-0 items-center justify-center text-current transition-colors">
|
||||
<div className={cn("mr-2 flex shrink-0 items-center text-current", iconClassName)}>
|
||||
{icon ?? (expanded ? (
|
||||
<FolderOpen size={iconSize} strokeWidth={1.9} />
|
||||
) : (
|
||||
@@ -169,8 +171,8 @@ export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
|
||||
/>
|
||||
) : (
|
||||
// leading-5 (not leading-none): CJK fallbacks like PingFang paint outside a 1.0 em box and get clipped by truncate.
|
||||
<span className="flex min-h-5 min-w-0 flex-1 items-center gap-1.5 leading-5">
|
||||
<span className="min-w-0 truncate" title={name}>{name}</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 leading-5">
|
||||
<span className="min-w-0 truncate translate-y-px" title={name}>{name}</span>
|
||||
{labelActions ? <span className="shrink-0">{labelActions}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
@@ -190,6 +192,7 @@ type VaultTreeItemRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childre
|
||||
depth: number;
|
||||
selected?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
iconClassName?: string;
|
||||
leading?: React.ReactNode;
|
||||
detail?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
@@ -205,6 +208,7 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
depth,
|
||||
selected = false,
|
||||
icon,
|
||||
iconClassName,
|
||||
leading,
|
||||
detail,
|
||||
actions,
|
||||
@@ -230,8 +234,10 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
data-selected={selected ? "true" : "false"}
|
||||
{...props}
|
||||
>
|
||||
{leading ?? <div className="mr-1 h-5 w-4 flex-shrink-0" />}
|
||||
{icon ? <div className="mr-2 flex shrink-0 items-center self-center">{icon}</div> : <FileText size={14} className="mr-2 shrink-0 text-muted-foreground" />}
|
||||
{leading ?? <div className="mr-1 w-4 shrink-0" />}
|
||||
<div className={cn("mr-2 flex shrink-0 items-center", !icon && "text-muted-foreground", iconClassName)}>
|
||||
{icon ?? <FileText size={14} />}
|
||||
</div>
|
||||
{content ?? (
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
{editing && onRenameCommit && onRenameCancel ? (
|
||||
@@ -242,7 +248,7 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
/>
|
||||
) : (
|
||||
// leading-5 keeps CJK glyphs inside the line box under truncate overflow.
|
||||
<div className="min-w-0 truncate leading-5" title={label}>{label}</div>
|
||||
<div className="min-w-0 flex-1 truncate leading-5 translate-y-px" title={label}>{label}</div>
|
||||
)}
|
||||
{detail && <div className="truncate text-xs leading-4 text-muted-foreground">{detail}</div>}
|
||||
</div>
|
||||
|
||||
@@ -380,6 +380,7 @@ export interface VaultNote {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
order?: number;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveNoteFontFamily,
|
||||
resolveNoteFontSelectionFamily,
|
||||
resolveNoteFontSelectionId,
|
||||
} from "./noteFonts.ts";
|
||||
|
||||
const fonts = [
|
||||
{ id: "menlo", family: "Menlo, monospace" },
|
||||
{ id: "fira", family: '"Fira Code", monospace' },
|
||||
];
|
||||
|
||||
test("note font settings store CSS families and read legacy ids", () => {
|
||||
assert.equal(resolveNoteFontSelectionFamily(fonts, "fira"), '"Fira Code", monospace');
|
||||
assert.equal(resolveNoteFontSelectionId(fonts, '"Fira Code", monospace'), "fira");
|
||||
assert.equal(resolveNoteFontFamily(fonts, "menlo"), "Menlo, monospace");
|
||||
assert.equal(resolveNoteFontFamily(fonts, "unknown"), "");
|
||||
assert.equal(resolveNoteFontSelectionId(fonts, ""), "");
|
||||
assert.equal(resolveNoteFontSelectionFamily([{ id: "", family: "" }, ...fonts], ""), "");
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface NoteFontOption {
|
||||
id: string;
|
||||
family: string;
|
||||
}
|
||||
|
||||
export const resolveNoteFontFamily = (
|
||||
fonts: NoteFontOption[],
|
||||
storedValue: string,
|
||||
): string => {
|
||||
if (!storedValue) return "";
|
||||
return fonts.find((font) => font.family === storedValue)?.family
|
||||
?? fonts.find((font) => font.id === storedValue)?.family
|
||||
?? "";
|
||||
};
|
||||
|
||||
export const resolveNoteFontSelectionId = (
|
||||
fonts: NoteFontOption[],
|
||||
storedValue: string,
|
||||
): string => fonts.find((font) => font.family === storedValue)?.id
|
||||
?? fonts.find((font) => font.id === storedValue)?.id
|
||||
?? "";
|
||||
|
||||
export const resolveNoteFontSelectionFamily = (
|
||||
fonts: NoteFontOption[],
|
||||
selectedId: string,
|
||||
): string => fonts.find((font) => font.id === selectedId)?.family ?? "";
|
||||
+343
-1
@@ -3,7 +3,14 @@ import test from "node:test";
|
||||
import {
|
||||
buildVaultNoteMarkdownExportFiles,
|
||||
buildVaultNoteFromMarkdownImport,
|
||||
calculateNoteStats,
|
||||
deriveNoteImportTitle,
|
||||
extractAllNoteTags,
|
||||
extractNoteHeadings,
|
||||
extractNoteSnippet,
|
||||
filterAndSortVaultNotes,
|
||||
formatMarkdownListSelection,
|
||||
formatMarkdownQuoteSelection,
|
||||
getVaultNotesForExportScope,
|
||||
importMarkdownFilesToVaultNotes,
|
||||
importMarkdownPayloadsToVaultNotes,
|
||||
@@ -16,7 +23,8 @@ import {
|
||||
sanitizeNoteExportFileNamePart,
|
||||
sanitizeNoteTitle,
|
||||
sanitizeVaultNote,
|
||||
} from "./notes";
|
||||
wrapMarkdownSyntax,
|
||||
} from "./notes.ts";
|
||||
|
||||
test("sanitizeVaultNote supplies safe defaults", () => {
|
||||
const note = sanitizeVaultNote({ title: " ", content: 123 as never });
|
||||
@@ -119,6 +127,9 @@ test("sanitizeNoteExportFileNamePart removes unsafe path characters", () => {
|
||||
assert.equal(sanitizeNoteExportFileNamePart("a\u0001b", "note"), "a-b");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("..", "note"), "note");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("CON", "note"), "CON_");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("CON.txt", "note"), "CON_.txt");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("com1.backup.md", "note"), "com1_.backup.md");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("release.CON", "note"), "release.CON");
|
||||
});
|
||||
|
||||
test("getVaultNotesForExportScope includes only selected folder descendants", () => {
|
||||
@@ -152,6 +163,34 @@ test("buildVaultNoteMarkdownExportFiles preserves groups and de-duplicates file
|
||||
assert.equal(buildVaultNoteMarkdownExportFiles(notes)[2].name, "note-3.md");
|
||||
});
|
||||
|
||||
test("buildVaultNoteMarkdownExportFiles keeps file names distinct from directory paths", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ id: "n1", title: "docs", content: "root", createdAt: 1, updatedAt: 1, order: 1000 }),
|
||||
sanitizeVaultNote({ id: "n2", title: "child", content: "nested", group: "DOCS.md", createdAt: 1, updatedAt: 1, order: 2000 }),
|
||||
];
|
||||
|
||||
assert.deepEqual(buildVaultNoteMarkdownExportFiles(notes), [
|
||||
{ name: "docs-2.md", content: "root" },
|
||||
{ name: "DOCS.md/child.md", content: "nested" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildVaultNoteMarkdownExportFiles keeps sanitized group paths distinct", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ id: "n1", title: "US", content: "one", group: "Prod:US/Apps", createdAt: 1, updatedAt: 1, order: 1000 }),
|
||||
sanitizeVaultNote({ id: "n2", title: "US", content: "two", group: "Prod?US/Apps", createdAt: 1, updatedAt: 1, order: 2000 }),
|
||||
sanitizeVaultNote({ id: "n3", title: "EU", content: "three", group: "Prod:US/Apps?EU", createdAt: 1, updatedAt: 1, order: 3000 }),
|
||||
sanitizeVaultNote({ id: "n4", title: "EU", content: "four", group: "Prod:US/Apps:EU", createdAt: 1, updatedAt: 1, order: 4000 }),
|
||||
];
|
||||
|
||||
assert.deepEqual(buildVaultNoteMarkdownExportFiles(notes), [
|
||||
{ name: "Prod-US/Apps/US.md", content: "one" },
|
||||
{ name: "Prod-US-2/Apps/US.md", content: "two" },
|
||||
{ name: "Prod-US/Apps-EU/EU.md", content: "three" },
|
||||
{ name: "Prod-US/Apps-EU-2/EU.md", content: "four" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveRenderedMarkdownLinkHref recovers ssh links sanitized by the editor DOM", () => {
|
||||
assert.equal(
|
||||
resolveRenderedMarkdownLinkHref(
|
||||
@@ -202,10 +241,22 @@ test("deriveNoteImportTitle prefers the first markdown heading", () => {
|
||||
deriveNoteImportTitle("script.md", "```sh\n# not a title\n```\n\n# Real Title"),
|
||||
"Real Title",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("tilde.md", "~~~md\n# not a title\n~~~\n\n# Tilde Fence Title"),
|
||||
"Tilde Fence Title",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("long-fence.md", "````md\n# not a title\n```\nstill fenced\n````\n\n# Long Fence Title"),
|
||||
"Long Fence Title",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("unclosed.md", "```sh\n# not a title\n\n# Also inside fence"),
|
||||
"unclosed",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("unclosed-tilde.md", "~~~sh\n# not a title\n\n# Also inside fence"),
|
||||
"unclosed-tilde",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("preface.md", "# Real Title\n\n```sh\n# not a title"),
|
||||
"Real Title",
|
||||
@@ -275,3 +326,294 @@ test("importMarkdownFilesToVaultNotes appends notes and skips unsupported files"
|
||||
assert.equal(result.notes[1].title, "Imported");
|
||||
assert.equal(result.notes[1].group, "Ops");
|
||||
});
|
||||
|
||||
test("extractNoteHeadings extracts markdown headings ignoring code blocks", () => {
|
||||
const content = `# Title 1
|
||||
Some intro text
|
||||
\`\`\`ts
|
||||
# Not a heading
|
||||
\`\`\`
|
||||
## Subtitle 1.1
|
||||
### Deep section
|
||||
`;
|
||||
const headings = extractNoteHeadings(content);
|
||||
assert.equal(headings.length, 3);
|
||||
assert.equal(headings[0].text, "Title 1");
|
||||
assert.equal(headings[0].level, 1);
|
||||
assert.equal(headings[1].text, "Subtitle 1.1");
|
||||
assert.equal(headings[1].level, 2);
|
||||
assert.equal(headings[2].text, "Deep section");
|
||||
assert.equal(headings[2].level, 3);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings follows rendered markdown fence and setext rules", () => {
|
||||
const content = `Setext title
|
||||
============
|
||||
|
||||
~~~md
|
||||
# Hidden in tilde fence
|
||||
~~~
|
||||
|
||||
\`\`\`\`md
|
||||
## Hidden in long fence
|
||||
\`\`\`
|
||||
still hidden
|
||||
\`\`\`\`
|
||||
|
||||
# Indented code
|
||||
### Visible ###`;
|
||||
const headings = extractNoteHeadings(content);
|
||||
|
||||
assert.deepEqual(
|
||||
headings.map(({ level, text, line }) => ({ level, text, line })),
|
||||
[
|
||||
{ level: 1, text: "Setext title", line: 1 },
|
||||
{ level: 3, text: "Visible", line: 15 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings does not turn thematic breaks after block headings into setext headings", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings("# First\n---\n## Second").map(({ level, text }) => ({ level, text })),
|
||||
[
|
||||
{ level: 1, text: "First" },
|
||||
{ level: 2, text: "Second" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings excludes reference definitions before thematic breaks", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings("[docs]: https://example.com\n---\n## Real").map(({ text }) => text),
|
||||
["Real"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings uses the same visible text as formatted rendered headings", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings("## **Bold**\n## ~~Removed~~ title\n## [Docs](https://example.com)\n## `code` © more\n## <https://example.com>\n## [Reference][docs]\n\n[docs]: /url")
|
||||
.map(({ text }) => text),
|
||||
["Bold", "Removed title", "Docs", "code © more", "https://example.com", "Reference"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings includes headings rendered inside markdown containers", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings("> ## Repeat\n## Repeat\n- ### Listed").map(({ level, text }) => ({ level, text })),
|
||||
[
|
||||
{ level: 2, text: "Repeat" },
|
||||
{ level: 2, text: "Repeat" },
|
||||
{ level: 3, text: "Listed" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings keeps complete multi-line setext heading text", () => {
|
||||
const [heading] = extractNoteHeadings("first line\nsecond line\n---");
|
||||
assert.deepEqual(
|
||||
{ level: heading.level, text: heading.text, line: heading.line },
|
||||
{ level: 2, text: "first line second line", line: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings ignores multi-line definitions and raw HTML blocks", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings('[foo]: /url\n "title"\n---\n\n<div>\ntext\n---\n</div>\n\n## Real').map(({ text }) => text),
|
||||
["Real"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteSnippet extracts clean plain text from markdown", () => {
|
||||
const content = `# Title
|
||||
Here is **bold** text and [a link](https://example.com) and \`code\`.
|
||||
- [x] Task 1
|
||||
> quote text
|
||||
`;
|
||||
const snippet = extractNoteSnippet(content, 100);
|
||||
assert.ok(!snippet.includes("#"));
|
||||
assert.ok(!snippet.includes("[a link]"));
|
||||
assert.ok(snippet.includes("bold"));
|
||||
assert.ok(snippet.includes("Task 1"));
|
||||
});
|
||||
|
||||
test("extractAllNoteTags aggregates and sorts tags by frequency", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ tags: ["dev", "prod"] }),
|
||||
sanitizeVaultNote({ tags: ["dev", "staging"] }),
|
||||
sanitizeVaultNote({ tags: ["dev"] }),
|
||||
];
|
||||
const tags = extractAllNoteTags(notes);
|
||||
assert.equal(tags[0].tag, "dev");
|
||||
assert.equal(tags[0].count, 3);
|
||||
});
|
||||
|
||||
test("filterAndSortVaultNotes filters by pinned, tags, group and sorts accordingly", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ id: "1", title: "B Note", isPinned: false, updatedAt: 100, tags: ["ssh"] }),
|
||||
sanitizeVaultNote({ id: "2", title: "A Note", isPinned: true, updatedAt: 50, tags: ["ssh"] }),
|
||||
sanitizeVaultNote({ id: "3", title: "C Note", isPinned: false, updatedAt: 200, group: "Ops" }),
|
||||
];
|
||||
|
||||
// Pinned first
|
||||
const sorted = filterAndSortVaultNotes(notes, { sort: "updatedDesc" });
|
||||
assert.equal(sorted[0].id, "2"); // Pinned note first
|
||||
assert.equal(sorted[1].id, "3"); // 200 updatedAt
|
||||
assert.equal(sorted[2].id, "1"); // 100 updatedAt
|
||||
|
||||
// Filter pinned only
|
||||
const pinnedOnly = filterAndSortVaultNotes(notes, { filterMode: "pinned" });
|
||||
assert.equal(pinnedOnly.length, 1);
|
||||
assert.equal(pinnedOnly[0].id, "2");
|
||||
|
||||
// Filter by tag
|
||||
const tagFiltered = filterAndSortVaultNotes(notes, { tag: "ssh" });
|
||||
assert.equal(tagFiltered.length, 2);
|
||||
});
|
||||
|
||||
test("calculateNoteStats calculates lines, chars, and word counts", () => {
|
||||
const content = "Hello world\n这是中文测试内容\nThird line";
|
||||
const stats = calculateNoteStats(content);
|
||||
assert.equal(stats.lines, 3);
|
||||
assert.equal(stats.chars, content.length);
|
||||
// Hello(1) + world(1) + 8 CJK chars + Third(1) + line(1) = 12
|
||||
assert.equal(stats.words, 12);
|
||||
});
|
||||
|
||||
test("wrapMarkdownSyntax wraps or inserts markdown syntax correctly", () => {
|
||||
const base = "Hello world";
|
||||
// bold on 'world' (start 6, end 11)
|
||||
const boldRes = wrapMarkdownSyntax(base, 6, 11, "bold");
|
||||
assert.equal(boldRes.text, "Hello **world**");
|
||||
|
||||
// insert table when no selection (start 0, end 0)
|
||||
const tableRes = wrapMarkdownSyntax("", 0, 0, "table");
|
||||
assert.ok(tableRes.text.includes("| Column 1 | Column 2 | Column 3 |"));
|
||||
|
||||
// insert code block
|
||||
const codeRes = wrapMarkdownSyntax("", 0, 0, "codeblock");
|
||||
assert.ok(codeRes.text.includes("```bash"));
|
||||
assert.equal(codeRes.selectionStart, "\n```bash\n".length);
|
||||
assert.equal(codeRes.selectionEnd, "\n```bash\n".length);
|
||||
|
||||
// insert math block
|
||||
const mathRes = wrapMarkdownSyntax("", 0, 0, "math");
|
||||
assert.ok(mathRes.text.includes("```latex"));
|
||||
assert.equal(mathRes.selectionStart, "\n```latex\n".length);
|
||||
assert.equal(mathRes.selectionEnd, "\n```latex\n".length);
|
||||
|
||||
const selectedCodeRes = wrapMarkdownSyntax("echo ok", 0, 7, "codeblock");
|
||||
assert.equal(
|
||||
selectedCodeRes.text.slice(selectedCodeRes.selectionStart, selectedCodeRes.selectionEnd),
|
||||
"echo ok",
|
||||
);
|
||||
|
||||
const selectedMathRes = wrapMarkdownSyntax("x^2", 0, 3, "math");
|
||||
assert.equal(
|
||||
selectedMathRes.text.slice(selectedMathRes.selectionStart, selectedMathRes.selectionEnd),
|
||||
"x^2",
|
||||
);
|
||||
|
||||
const bulletRes = wrapMarkdownSyntax("one\ntwo", 0, 7, "bullet");
|
||||
assert.equal(bulletRes.text, "\n- one\n- two\n");
|
||||
assert.equal(
|
||||
bulletRes.text.slice(bulletRes.selectionStart, bulletRes.selectionEnd),
|
||||
"one\n- two",
|
||||
);
|
||||
|
||||
const numberRes = wrapMarkdownSyntax("one\ntwo", 0, 7, "number");
|
||||
assert.equal(numberRes.text, "\n1. one\n2. two\n");
|
||||
assert.equal(
|
||||
numberRes.text.slice(numberRes.selectionStart, numberRes.selectionEnd),
|
||||
"one\n2. two",
|
||||
);
|
||||
|
||||
const taskRes = wrapMarkdownSyntax("one\ntwo", 0, 7, "task");
|
||||
assert.equal(taskRes.text, "\n- [ ] one\n- [ ] two\n");
|
||||
assert.equal(
|
||||
taskRes.text.slice(taskRes.selectionStart, taskRes.selectionEnd),
|
||||
"one\n- [ ] two",
|
||||
);
|
||||
|
||||
const quoteRes = wrapMarkdownSyntax("one\n\ntwo", 0, 8, "quote");
|
||||
assert.equal(quoteRes.text, "\n> one\n>\n> two\n");
|
||||
assert.equal(
|
||||
quoteRes.text.slice(quoteRes.selectionStart, quoteRes.selectionEnd),
|
||||
"one\n>\n> two",
|
||||
);
|
||||
|
||||
const trailingNewlineQuoteRes = wrapMarkdownSyntax("one\ntwo\nthree", 0, 8, "quote");
|
||||
assert.equal(trailingNewlineQuoteRes.text, "\n> one\n> two\n>\nthree");
|
||||
assert.equal(
|
||||
trailingNewlineQuoteRes.text.slice(
|
||||
trailingNewlineQuoteRes.selectionStart,
|
||||
trailingNewlineQuoteRes.selectionEnd,
|
||||
),
|
||||
"one\n> two",
|
||||
);
|
||||
|
||||
const leadingNewlineQuoteRes = wrapMarkdownSyntax("\none", 0, 4, "quote");
|
||||
assert.equal(leadingNewlineQuoteRes.text, "\n>\n> one\n");
|
||||
assert.equal(
|
||||
leadingNewlineQuoteRes.text.slice(
|
||||
leadingNewlineQuoteRes.selectionStart,
|
||||
leadingNewlineQuoteRes.selectionEnd,
|
||||
),
|
||||
"one",
|
||||
);
|
||||
|
||||
const newlineOnlyQuoteRes = wrapMarkdownSyntax("\n", 0, 1, "quote");
|
||||
assert.equal(newlineOnlyQuoteRes.text, "\n> Quote\n");
|
||||
assert.equal(
|
||||
newlineOnlyQuoteRes.text.slice(newlineOnlyQuoteRes.selectionStart, newlineOnlyQuoteRes.selectionEnd),
|
||||
"Quote",
|
||||
);
|
||||
|
||||
const trailingNewlineBulletRes = wrapMarkdownSyntax("one\ntwo\nthree", 0, 8, "bullet");
|
||||
assert.equal(trailingNewlineBulletRes.text, "\n- one\n- two\n\nthree");
|
||||
assert.equal(
|
||||
trailingNewlineBulletRes.text.slice(
|
||||
trailingNewlineBulletRes.selectionStart,
|
||||
trailingNewlineBulletRes.selectionEnd,
|
||||
),
|
||||
"one\n- two",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
wrapMarkdownSyntax("one\ntwo\nthree", 0, 8, "number").text,
|
||||
/^3\.\s*$/m,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
wrapMarkdownSyntax("one\ntwo\nthree", 0, 8, "task").text,
|
||||
/^- \[ \]\s*$/m,
|
||||
);
|
||||
|
||||
const blankLinesNumberRes = wrapMarkdownSyntax("one\n\ntwo\n\n\nthree", 0, 11, "number");
|
||||
assert.equal(blankLinesNumberRes.text, "\n1. one\n\n2. two\n\n\n\nthree");
|
||||
assert.equal(
|
||||
blankLinesNumberRes.text.slice(
|
||||
blankLinesNumberRes.selectionStart,
|
||||
blankLinesNumberRes.selectionEnd,
|
||||
),
|
||||
"one\n\n2. two",
|
||||
);
|
||||
|
||||
for (const action of ["bullet", "number", "task"] as const) {
|
||||
const newlineOnlyRes = wrapMarkdownSyntax("\n", 0, 1, action);
|
||||
const expected = action === "bullet" ? "- List item" : action === "number" ? "1. List item" : "- [ ] Task";
|
||||
assert.equal(newlineOnlyRes.text, `\n${expected}\n`);
|
||||
assert.equal(
|
||||
newlineOnlyRes.text.slice(newlineOnlyRes.selectionStart, newlineOnlyRes.selectionEnd),
|
||||
action === "task" ? "Task" : "List item",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("formatMarkdownListSelection prefixes every non-empty selected line", () => {
|
||||
assert.equal(formatMarkdownListSelection("one\ntwo", "bullet"), "- one\n- two");
|
||||
assert.equal(formatMarkdownListSelection("one\n\ntwo", "number"), "1. one\n\n2. two");
|
||||
assert.equal(formatMarkdownListSelection("one\ntwo", "task"), "- [ ] one\n- [ ] two");
|
||||
});
|
||||
|
||||
test("formatMarkdownQuoteSelection prefixes every selected line", () => {
|
||||
assert.equal(formatMarkdownQuoteSelection("one\n\ntwo"), "> one\n>\n> two");
|
||||
});
|
||||
+521
-34
@@ -1,5 +1,11 @@
|
||||
import type { Host, VaultNote } from "./models";
|
||||
import { getNextVaultOrder, normalizeVaultOrder, sortByVaultOrder } from "./vaultOrder";
|
||||
import type { Host, VaultNote } from "./models.ts";
|
||||
import { getNextVaultOrder, normalizeVaultOrder, sortByVaultOrder } from "./vaultOrder.ts";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
|
||||
import { toString as markdownNodeToString } from "mdast-util-to-string";
|
||||
import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
|
||||
|
||||
export type { Host, VaultNote };
|
||||
|
||||
const cleanStringArray = (values: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
@@ -38,6 +44,7 @@ export const sanitizeVaultNote = (note: Partial<VaultNote>): VaultNote => {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
order: typeof note.order === "number" && Number.isFinite(note.order) ? note.order : undefined,
|
||||
isPinned: note.isPinned ? true : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -172,7 +179,12 @@ export const sanitizeNoteExportFileNamePart = (value: string | undefined, fallba
|
||||
.replace(/\.+$/g, "")
|
||||
.trim();
|
||||
const safe = cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : fallback;
|
||||
const withoutReservedName = NOTE_EXPORT_RESERVED_WINDOWS_NAMES.test(safe) ? `${safe}_` : safe;
|
||||
const firstDotIndex = safe.indexOf(".");
|
||||
const windowsStem = (firstDotIndex >= 0 ? safe.slice(0, firstDotIndex) : safe).trimEnd();
|
||||
const extension = firstDotIndex >= 0 ? safe.slice(firstDotIndex) : "";
|
||||
const withoutReservedName = NOTE_EXPORT_RESERVED_WINDOWS_NAMES.test(windowsStem)
|
||||
? `${windowsStem}_${extension}`
|
||||
: safe;
|
||||
return withoutReservedName.slice(0, 120) || fallback;
|
||||
};
|
||||
|
||||
@@ -192,21 +204,53 @@ export const buildVaultNoteMarkdownExportFiles = (
|
||||
notes: VaultNote[],
|
||||
scope: VaultNotesExportScope = { type: "all" },
|
||||
): VaultNoteMarkdownExportFile[] => {
|
||||
const notesForExport = getVaultNotesForExportScope(notes, scope);
|
||||
const groupPaths = Array.from(new Set(
|
||||
notesForExport.flatMap((note) => note.group ? ancestorNoteGroupPaths(note.group) : []),
|
||||
)).sort((left, right) => {
|
||||
return left.split("/").length - right.split("/").length;
|
||||
});
|
||||
const exportedGroupSegments = new Map<string, string[]>();
|
||||
const usedDirectoryNamesByParent = new Map<string, Set<string>>();
|
||||
|
||||
for (const groupPath of groupPaths) {
|
||||
const parentPath = getNoteGroupParentPath(groupPath);
|
||||
const parentSegments = parentPath ? exportedGroupSegments.get(parentPath) ?? [] : [];
|
||||
const parentKey = parentSegments.join("/").toLowerCase();
|
||||
const usedDirectoryNames = usedDirectoryNamesByParent.get(parentKey) ?? new Set<string>();
|
||||
const baseName = sanitizeNoteExportFileNamePart(getNoteGroupLeafName(groupPath), "folder");
|
||||
let candidate = baseName;
|
||||
let suffix = 2;
|
||||
while (usedDirectoryNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${baseName}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedDirectoryNames.add(candidate.toLowerCase());
|
||||
usedDirectoryNamesByParent.set(parentKey, usedDirectoryNames);
|
||||
exportedGroupSegments.set(groupPath, [...parentSegments, candidate]);
|
||||
}
|
||||
|
||||
const exportEntries = notesForExport.map((note, index) => ({
|
||||
note,
|
||||
groupSegments: note.group
|
||||
? exportedGroupSegments.get(cleanNoteGroupPath(note.group)) ?? []
|
||||
: [],
|
||||
baseName: sanitizeNoteExportFileNamePart(note.title, `note-${index + 1}`),
|
||||
}));
|
||||
const directoryNames = new Set<string>();
|
||||
for (const { groupSegments } of exportEntries) {
|
||||
for (let depth = 1; depth <= groupSegments.length; depth += 1) {
|
||||
directoryNames.add(groupSegments.slice(0, depth).join("/").toLowerCase());
|
||||
}
|
||||
}
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
return getVaultNotesForExportScope(notes, scope).map((note, index) => {
|
||||
const groupSegments = note.group
|
||||
? cleanNoteGroupPath(note.group)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part) => sanitizeNoteExportFileNamePart(part, "folder"))
|
||||
: [];
|
||||
const baseName = sanitizeNoteExportFileNamePart(note.title, `note-${index + 1}`);
|
||||
return exportEntries.map(({ note, groupSegments, baseName }) => {
|
||||
const basePath = [...groupSegments, baseName].join("/");
|
||||
let candidate = `${basePath}.md`;
|
||||
let suffix = 2;
|
||||
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
while (usedNames.has(candidate.toLowerCase()) || directoryNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${basePath}-${suffix}.md`;
|
||||
suffix += 1;
|
||||
}
|
||||
@@ -354,29 +398,9 @@ export const resolveRenderedMarkdownLinkHref = (
|
||||
|
||||
const NOTE_IMPORT_TITLE_EXTENSIONS = /\.(md|markdown|txt)$/i;
|
||||
|
||||
const stripFencedCodeBlocksForImportTitle = (content: string): string => {
|
||||
const withoutClosedBlocks = content.replace(/```[\s\S]*?```/g, "");
|
||||
const lines = withoutClosedBlocks.split("\n");
|
||||
const output: string[] = [];
|
||||
let inUnclosedFence = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (/^```/.test(trimmed)) {
|
||||
inUnclosedFence = true;
|
||||
continue;
|
||||
}
|
||||
if (inUnclosedFence) continue;
|
||||
output.push(line);
|
||||
}
|
||||
|
||||
return output.join("\n");
|
||||
};
|
||||
|
||||
export const deriveNoteImportTitle = (fileName: string, content: string): string => {
|
||||
const contentWithoutCodeBlocks = stripFencedCodeBlocksForImportTitle(content);
|
||||
const headingMatch = /^#\s+(.+?)\s*$/m.exec(contentWithoutCodeBlocks);
|
||||
if (headingMatch?.[1]) return headingMatch[1].trim();
|
||||
const heading = extractNoteHeadings(content).find((item) => item.level === 1);
|
||||
if (heading) return heading.text;
|
||||
|
||||
const baseName = fileName.replace(NOTE_IMPORT_TITLE_EXTENSIONS, "").trim();
|
||||
return baseName || "Untitled note";
|
||||
@@ -462,3 +486,466 @@ export const importMarkdownFilesToVaultNotes = async (
|
||||
skippedCount,
|
||||
};
|
||||
};
|
||||
|
||||
export interface NoteHeadingItem {
|
||||
id: string;
|
||||
level: number;
|
||||
text: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export const normalizeNoteHeadingText = (value: string): string => value.replace(/\s+/g, " ").trim();
|
||||
|
||||
export const extractNoteHeadings = (content: string): NoteHeadingItem[] => {
|
||||
if (!content) return [];
|
||||
const headings: NoteHeadingItem[] = [];
|
||||
type AstNode = {
|
||||
type: string;
|
||||
depth?: number;
|
||||
children?: AstNode[];
|
||||
position?: { start?: { line?: number } };
|
||||
};
|
||||
const visit = (node: AstNode) => {
|
||||
if (node.type === "heading" && node.depth) {
|
||||
const text = normalizeNoteHeadingText(
|
||||
markdownNodeToString(node as Parameters<typeof markdownNodeToString>[0]),
|
||||
);
|
||||
const line = node.position?.start?.line ?? 1;
|
||||
if (text) {
|
||||
headings.push({
|
||||
id: `heading-${line - 1}-${text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-")}`,
|
||||
level: node.depth,
|
||||
text,
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
node.children?.forEach(visit);
|
||||
};
|
||||
visit(
|
||||
fromMarkdown(content, {
|
||||
extensions: [gfmStrikethrough()],
|
||||
mdastExtensions: [gfmStrikethroughFromMarkdown()],
|
||||
}) as AstNode,
|
||||
);
|
||||
return headings;
|
||||
};
|
||||
|
||||
export const extractNoteSnippet = (content: string, maxLength = 100): string => {
|
||||
if (!content) return "";
|
||||
let text = content.replace(/```[\s\S]*?```/g, " ");
|
||||
text = text.replace(/`([^`]+)`/g, "$1");
|
||||
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
text = text.replace(/^#{1,6}\s+/gm, "");
|
||||
text = text.replace(/^>\s+/gm, "");
|
||||
text = text.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/gm, "");
|
||||
text = text.replace(/^\d+\.\s+/gm, "");
|
||||
text = text.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trim()}...`;
|
||||
};
|
||||
|
||||
export const extractAllNoteTags = (notes: VaultNote[]): { tag: string; count: number }[] => {
|
||||
const map = new Map<string, number>();
|
||||
for (const note of notes) {
|
||||
if (note.tags) {
|
||||
for (const t of note.tags) {
|
||||
const clean = t.trim();
|
||||
if (clean) {
|
||||
map.set(clean, (map.get(clean) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
};
|
||||
|
||||
export type NoteSortOption =
|
||||
| "updatedDesc"
|
||||
| "updatedAsc"
|
||||
| "createdDesc"
|
||||
| "createdAsc"
|
||||
| "titleAsc"
|
||||
| "titleDesc"
|
||||
| "custom";
|
||||
|
||||
export type NoteFilterMode = "all" | "pinned" | "recent" | "uncategorized";
|
||||
|
||||
export const filterAndSortVaultNotes = (
|
||||
notes: VaultNote[],
|
||||
options: {
|
||||
search?: string;
|
||||
group?: string | null;
|
||||
tag?: string | null;
|
||||
filterMode?: NoteFilterMode;
|
||||
sort?: NoteSortOption;
|
||||
hosts?: Host[];
|
||||
} = {},
|
||||
): VaultNote[] => {
|
||||
const {
|
||||
search = "",
|
||||
group = null,
|
||||
tag = null,
|
||||
filterMode = "all",
|
||||
sort = "updatedDesc",
|
||||
hosts = [],
|
||||
} = options;
|
||||
|
||||
const filtered = notes.filter((note) => {
|
||||
if (filterMode === "pinned" && !note.isPinned) return false;
|
||||
if (filterMode === "uncategorized" && note.group) return false;
|
||||
if (filterMode === "recent") {
|
||||
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
if (note.updatedAt < sevenDaysAgo) return false;
|
||||
}
|
||||
|
||||
if (group) {
|
||||
if (!note.group || !isNoteGroupInside(note.group, group)) return false;
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
if (!note.tags || !note.tags.includes(tag)) return false;
|
||||
}
|
||||
|
||||
if (search.trim() && !matchesVaultNoteSearch(note, search, hosts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (sort !== "custom") {
|
||||
if (a.isPinned !== b.isPinned) {
|
||||
return a.isPinned ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
switch (sort) {
|
||||
case "updatedDesc":
|
||||
return b.updatedAt - a.updatedAt;
|
||||
case "updatedAsc":
|
||||
return a.updatedAt - b.updatedAt;
|
||||
case "createdDesc":
|
||||
return b.createdAt - a.createdAt;
|
||||
case "createdAsc":
|
||||
return a.createdAt - b.createdAt;
|
||||
case "titleAsc":
|
||||
return (a.title || "").localeCompare(b.title || "");
|
||||
case "titleDesc":
|
||||
return (b.title || "").localeCompare(a.title || "");
|
||||
case "custom":
|
||||
default:
|
||||
return (a.order ?? 0) - (b.order ?? 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export interface NoteStats {
|
||||
words: number;
|
||||
chars: number;
|
||||
lines: number;
|
||||
}
|
||||
|
||||
export const calculateNoteStats = (content: string): NoteStats => {
|
||||
if (!content) return { words: 0, chars: 0, lines: 0 };
|
||||
const lines = content.split("\n").length;
|
||||
const chars = content.length;
|
||||
const cjkMatches = content.match(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/g);
|
||||
const cjkChars = cjkMatches ? cjkMatches.length : 0;
|
||||
const nonCjkWords = content
|
||||
.replace(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length;
|
||||
const words = cjkChars + nonCjkWords;
|
||||
return { words, chars, lines };
|
||||
};
|
||||
|
||||
export type MarkdownActionType =
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "bold"
|
||||
| "italic"
|
||||
| "strikethrough"
|
||||
| "underline"
|
||||
| "code"
|
||||
| "codeblock"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "quote"
|
||||
| "bullet"
|
||||
| "number"
|
||||
| "task"
|
||||
| "table"
|
||||
| "divider"
|
||||
| "link"
|
||||
| "image"
|
||||
| "math";
|
||||
|
||||
export interface WrapMarkdownResult {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
const formatSelectedMarkdownListLines = (
|
||||
content: string,
|
||||
marker: (index: number) => string,
|
||||
): { formatted: string; selectionStartOffset: number; selectionEndOffset: number } => {
|
||||
let itemIndex = 0;
|
||||
let cursor = 0;
|
||||
let selectionStartOffset: number | null = null;
|
||||
let selectionEndOffset: number | null = null;
|
||||
const formattedLines = content.split("\n").map((line) => {
|
||||
if (!line.trim()) {
|
||||
cursor += line.length + 1;
|
||||
return line;
|
||||
}
|
||||
const prefix = marker(itemIndex);
|
||||
itemIndex += 1;
|
||||
const formattedLine = `${prefix}${line}`;
|
||||
if (selectionStartOffset === null) selectionStartOffset = cursor + prefix.length;
|
||||
selectionEndOffset = cursor + formattedLine.length;
|
||||
cursor += formattedLine.length + 1;
|
||||
return formattedLine;
|
||||
});
|
||||
return {
|
||||
formatted: formattedLines.join("\n"),
|
||||
selectionStartOffset: selectionStartOffset ?? 0,
|
||||
selectionEndOffset: selectionEndOffset ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatMarkdownListSelection = (
|
||||
content: string,
|
||||
action: "bullet" | "number" | "task",
|
||||
): string => formatSelectedMarkdownListLines(
|
||||
content,
|
||||
action === "number"
|
||||
? (index) => `${index + 1}. `
|
||||
: action === "task"
|
||||
? () => "- [ ] "
|
||||
: () => "- ",
|
||||
).formatted;
|
||||
|
||||
const formatSelectedMarkdownQuoteLines = (content: string): {
|
||||
formatted: string;
|
||||
selectionStartOffset: number;
|
||||
selectionEndOffset: number;
|
||||
} => {
|
||||
let cursor = 0;
|
||||
let selectionStartOffset: number | null = null;
|
||||
let selectionEndOffset: number | null = null;
|
||||
const formattedLines = content.split("\n").map((line) => {
|
||||
const prefix = line ? "> " : ">";
|
||||
const formattedLine = `${prefix}${line}`;
|
||||
if (line) {
|
||||
if (selectionStartOffset === null) selectionStartOffset = cursor + prefix.length;
|
||||
selectionEndOffset = cursor + formattedLine.length;
|
||||
}
|
||||
cursor += formattedLine.length + 1;
|
||||
return formattedLine;
|
||||
});
|
||||
return {
|
||||
formatted: formattedLines.join("\n"),
|
||||
selectionStartOffset: selectionStartOffset ?? 0,
|
||||
selectionEndOffset: selectionEndOffset ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatMarkdownQuoteSelection = (content: string): string =>
|
||||
formatSelectedMarkdownQuoteLines(content).formatted;
|
||||
|
||||
export const wrapMarkdownSyntax = (
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
action: MarkdownActionType,
|
||||
): WrapMarkdownResult => {
|
||||
const before = text.slice(0, start);
|
||||
const selected = text.slice(start, end);
|
||||
const after = text.slice(end);
|
||||
const selectedBlockContent = (fallback: string): string => selected.trim() ? selected : fallback;
|
||||
|
||||
switch (action) {
|
||||
case "bold": {
|
||||
const content = selected || "bold text";
|
||||
return {
|
||||
text: `${before}**${content}**${after}`,
|
||||
selectionStart: start + 2,
|
||||
selectionEnd: start + 2 + content.length,
|
||||
};
|
||||
}
|
||||
case "italic": {
|
||||
const content = selected || "italic text";
|
||||
return {
|
||||
text: `${before}*${content}*${after}`,
|
||||
selectionStart: start + 1,
|
||||
selectionEnd: start + 1 + content.length,
|
||||
};
|
||||
}
|
||||
case "strikethrough": {
|
||||
const content = selected || "strikethrough text";
|
||||
return {
|
||||
text: `${before}~~${content}~~${after}`,
|
||||
selectionStart: start + 2,
|
||||
selectionEnd: start + 2 + content.length,
|
||||
};
|
||||
}
|
||||
case "underline": {
|
||||
const content = selected || "underlined text";
|
||||
return {
|
||||
text: `${before}<u>${content}</u>${after}`,
|
||||
selectionStart: start + 3,
|
||||
selectionEnd: start + 3 + content.length,
|
||||
};
|
||||
}
|
||||
case "code": {
|
||||
const content = selected || "code";
|
||||
return {
|
||||
text: `${before}\`${content}\`${after}`,
|
||||
selectionStart: start + 1,
|
||||
selectionEnd: start + 1 + content.length,
|
||||
};
|
||||
}
|
||||
case "codeblock": {
|
||||
const content = selected || "";
|
||||
const opening = "\n```bash\n";
|
||||
const insert = `${opening}${content}\n\`\`\`\n`;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + opening.length,
|
||||
selectionEnd: start + opening.length + content.length,
|
||||
};
|
||||
}
|
||||
case "h1": {
|
||||
const content = selected || "Heading 1";
|
||||
return {
|
||||
text: `${before}\n# ${content}\n${after}`,
|
||||
selectionStart: start + 3,
|
||||
selectionEnd: start + 3 + content.length,
|
||||
};
|
||||
}
|
||||
case "h2": {
|
||||
const content = selected || "Heading 2";
|
||||
return {
|
||||
text: `${before}\n## ${content}\n${after}`,
|
||||
selectionStart: start + 4,
|
||||
selectionEnd: start + 4 + content.length,
|
||||
};
|
||||
}
|
||||
case "h3": {
|
||||
const content = selected || "Heading 3";
|
||||
return {
|
||||
text: `${before}\n### ${content}\n${after}`,
|
||||
selectionStart: start + 5,
|
||||
selectionEnd: start + 5 + content.length,
|
||||
};
|
||||
}
|
||||
case "h4": {
|
||||
const content = selected || "Heading 4";
|
||||
return {
|
||||
text: `${before}\n#### ${content}\n${after}`,
|
||||
selectionStart: start + 6,
|
||||
selectionEnd: start + 6 + content.length,
|
||||
};
|
||||
}
|
||||
case "quote": {
|
||||
const content = selectedBlockContent("Quote");
|
||||
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownQuoteLines(content);
|
||||
return {
|
||||
text: `${before}\n${formatted}\n${after}`,
|
||||
selectionStart: start + 1 + selectionStartOffset,
|
||||
selectionEnd: start + 1 + selectionEndOffset,
|
||||
};
|
||||
}
|
||||
case "bullet": {
|
||||
const content = selectedBlockContent("List item");
|
||||
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
|
||||
content,
|
||||
() => "- ",
|
||||
);
|
||||
return {
|
||||
text: `${before}\n${formatted}\n${after}`,
|
||||
selectionStart: start + 1 + selectionStartOffset,
|
||||
selectionEnd: start + 1 + selectionEndOffset,
|
||||
};
|
||||
}
|
||||
case "number": {
|
||||
const content = selectedBlockContent("List item");
|
||||
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
|
||||
content,
|
||||
(index) => `${index + 1}. `,
|
||||
);
|
||||
return {
|
||||
text: `${before}\n${formatted}\n${after}`,
|
||||
selectionStart: start + 1 + selectionStartOffset,
|
||||
selectionEnd: start + 1 + selectionEndOffset,
|
||||
};
|
||||
}
|
||||
case "task": {
|
||||
const content = selectedBlockContent("Task");
|
||||
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
|
||||
content,
|
||||
() => "- [ ] ",
|
||||
);
|
||||
return {
|
||||
text: `${before}\n${formatted}\n${after}`,
|
||||
selectionStart: start + 1 + selectionStartOffset,
|
||||
selectionEnd: start + 1 + selectionEndOffset,
|
||||
};
|
||||
}
|
||||
case "divider": {
|
||||
const insert = `\n---\n`;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + insert.length,
|
||||
selectionEnd: start + insert.length,
|
||||
};
|
||||
}
|
||||
case "table": {
|
||||
const tableMarkdown = `\n| Column 1 | Column 2 | Column 3 |\n| :--- | :--- | :--- |\n| Cell 1 | Cell 2 | Cell 3 |\n`;
|
||||
return {
|
||||
text: `${before}${tableMarkdown}${after}`,
|
||||
selectionStart: start + tableMarkdown.length,
|
||||
selectionEnd: start + tableMarkdown.length,
|
||||
};
|
||||
}
|
||||
case "link": {
|
||||
const title = selected || "Link text";
|
||||
const insert = `[${title}](https://)`;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + title.length + 3,
|
||||
selectionEnd: start + title.length + 11,
|
||||
};
|
||||
}
|
||||
case "image": {
|
||||
const alt = selected || "Image description";
|
||||
const insert = ``;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + alt.length + 4,
|
||||
selectionEnd: start + alt.length + 12,
|
||||
};
|
||||
}
|
||||
case "math": {
|
||||
const content = selected || "";
|
||||
const opening = "\n```latex\n";
|
||||
const insert = `${opening}${content}\n\`\`\`\n`;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + opening.length,
|
||||
selectionEnd: start + opening.length + content.length,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { text, selectionStart: start, selectionEnd: end };
|
||||
}
|
||||
};
|
||||
@@ -896,7 +896,7 @@ export const resolveNoteClipboardPaste = (input: {
|
||||
};
|
||||
|
||||
export const shouldInterceptResolvedNotePaste = (input: {
|
||||
editorMode: "edit" | "preview";
|
||||
editorMode: "edit" | "preview" | "source" | "live" | string;
|
||||
pasteInsideCodeBlock: boolean;
|
||||
payload: NoteClipboardPastePayload;
|
||||
}): boolean => {
|
||||
|
||||
@@ -302,6 +302,9 @@ export interface SyncPayload {
|
||||
uiFontFamilyId?: string;
|
||||
uiLanguage?: string;
|
||||
customCSS?: string;
|
||||
noteFontFamily?: string;
|
||||
noteFontSize?: number;
|
||||
noteCodeFontSize?: number;
|
||||
// Terminal
|
||||
terminalTheme?: string;
|
||||
followAppTerminalTheme?: boolean;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
@@ -9,6 +10,11 @@ import {
|
||||
shouldUsePluginTerminalCompletionProvider,
|
||||
} from './terminalPromptSecurity.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const sharedPromptSecurity = require('./terminalPromptSecurity.shared.cjs') as {
|
||||
isUntrustedTerminalInputPrompt: typeof isUntrustedTerminalInputPrompt;
|
||||
};
|
||||
|
||||
test('terminal prompt security recognizes password, MFA, OTP, PIN, and CJK challenges', () => {
|
||||
const challenges = [
|
||||
'Password: ',
|
||||
@@ -115,3 +121,24 @@ test('ordinary shell commands ending with a colon are not untrusted input prompt
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Token: '), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
|
||||
});
|
||||
|
||||
test('renderer and main-process prompt classifiers stay aligned', () => {
|
||||
const cases: Array<[
|
||||
string,
|
||||
{ allowHostStyleGreaterThan?: boolean } | undefined,
|
||||
boolean,
|
||||
]> = [
|
||||
['Custom authentication> ', undefined, true],
|
||||
['Please authenticate: ', undefined, true],
|
||||
['alice@host:~$ ', undefined, false],
|
||||
['user@host:~$ lsof -i:', undefined, false],
|
||||
['Challenge # 1:', undefined, true],
|
||||
['router> show ip:', { allowHostStyleGreaterThan: true }, false],
|
||||
['router> show ip:', undefined, true],
|
||||
];
|
||||
|
||||
for (const [prompt, options, expected] of cases) {
|
||||
assert.equal(isUntrustedTerminalInputPrompt(prompt, options), expected, prompt);
|
||||
assert.equal(sharedPromptSecurity.isUntrustedTerminalInputPrompt(prompt, options), expected, prompt);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import sharedPromptSecurity from './terminalPromptSecurity.shared.cjs';
|
||||
|
||||
const ESCAPE_SEQUENCE = "\\x" + "1b";
|
||||
const BELL_SEQUENCE = "\\x" + "07";
|
||||
const ANSI_CONTROL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[[0-?]*[ -/]*[@-~]`, 'gu');
|
||||
@@ -136,6 +134,52 @@ export function shouldUsePluginTerminalCompletionProvider(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Body text before a `$` / `#` / `%` terminator that is plausible as a shell
|
||||
* PS1 (bare marker, user@host, path, shell-version, or lowercase identity).
|
||||
* Rejects English challenge labels that only happen to end with those markers
|
||||
* (`Challenge #`, `Account $`).
|
||||
*/
|
||||
function isPlausibleShellPromptBody(body: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return true;
|
||||
if (/@|[/\\~:]/u.test(trimmed)) return true;
|
||||
if (/^(?:bash|zsh|sh|fish|ksh|csh|tcsh|dash)(?:-[\d.]+)?$/iu.test(trimmed)) return true;
|
||||
// Single lowercase identity token: root, ubuntu, pi — not Title-Case labels.
|
||||
return /^[a-z0-9_.-]+$/u.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a confirmed shell/device prompt is followed by typed text on the
|
||||
* same logical line (e.g. `user@host:~$ lsof -i:`). Trailing `:` / `>` in that
|
||||
* typed text is ordinary command input, not an authentication boundary.
|
||||
*
|
||||
* Requires a real prompt boundary (terminator + whitespace) so mid-label
|
||||
* shapes like `Challenge #1:` stay fail-closed. For `$`/`#`/`%`, also requires
|
||||
* a plausible PS1 body so `Challenge # 1:` / `Account $ code:` stay untrusted.
|
||||
*/
|
||||
function hasTypedInputAfterConfirmedPrompt(
|
||||
line: string,
|
||||
options: ConfirmedPromptOptions = {},
|
||||
): boolean {
|
||||
for (let i = 0; i < line.length - 1; i += 1) {
|
||||
const ch = line[i];
|
||||
// Only terminators that can end a confirmed prompt without relying on a
|
||||
// glyph appearing later in the typed command.
|
||||
if (ch !== '$' && ch !== '#' && ch !== '%' && ch !== '>') continue;
|
||||
const rest = line.slice(i + 1);
|
||||
// Prompt terminators are followed by whitespace before typed input.
|
||||
if (!/^\s+\S/u.test(rest)) continue;
|
||||
const candidate = line.slice(0, i + 1);
|
||||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||||
if ((ch === '$' || ch === '#' || ch === '%') && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed for prompt-shaped input boundaries that are not positively
|
||||
* identified as an ordinary shell/device prompt. This protects custom PAM,
|
||||
@@ -145,7 +189,14 @@ export function isUntrustedTerminalInputPrompt(
|
||||
value: string,
|
||||
options: ConfirmedPromptOptions = {},
|
||||
): boolean {
|
||||
return sharedPromptSecurity.isUntrustedTerminalInputPrompt(value, options);
|
||||
const prompt = lastLogicalLine(value).trim();
|
||||
if (!prompt) return false;
|
||||
if (isSensitiveTerminalChallenge(prompt)) return true;
|
||||
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
||||
// Mid-command punctuation after a real shell prompt must keep broadcasting
|
||||
// (#2709). Standalone `Label:` / `Custom>` challenges still fail closed.
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
}
|
||||
|
||||
export { MAX_PROMPT_SECURITY_TAIL_CHARS };
|
||||
@@ -115,6 +115,7 @@ module.exports = {
|
||||
'!node_modules/lexical/**/*',
|
||||
'!node_modules/@lexical/**/*',
|
||||
'!node_modules/@codemirror/**/*',
|
||||
'!node_modules/katex/**/*',
|
||||
'!node_modules/shiki/**/*',
|
||||
'!node_modules/@shiki/**/*',
|
||||
// Heavy cloud completion specs are intentionally not bundled. The main
|
||||
|
||||
@@ -21,6 +21,13 @@ function channelAbortError(signal, label) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function monotonicNow() {
|
||||
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bastion / jump hosts sometimes reject rapid session channel opens with a
|
||||
* distinctive rate-limit message. The common Chinese bastion typo "offen"
|
||||
@@ -32,9 +39,37 @@ function isSshChannelOpenRateLimitedError(error) {
|
||||
|| /channelOpen\s+too\s+often\b/i.test(message);
|
||||
}
|
||||
|
||||
function sleep(ms, sleepFn = null) {
|
||||
if (typeof sleepFn === "function") return Promise.resolve(sleepFn(ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
function sleep(ms, sleepFn = null, signal = null, label = "SSH channel retry") {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const onAbort = () => finish(channelAbortError(signal, label));
|
||||
|
||||
if (signal?.aborted) {
|
||||
finish(channelAbortError(signal, label));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
if (typeof sleepFn === "function") {
|
||||
void Promise.resolve()
|
||||
.then(() => sleepFn(ms))
|
||||
.then(() => finish(), (error) => finish(error));
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => finish(), ms);
|
||||
});
|
||||
}
|
||||
|
||||
function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
@@ -46,31 +81,47 @@ function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
const signal = options.signal || null;
|
||||
const closeLateResult = options.closeLateResult || closeLateChannel;
|
||||
const timeoutCode = options.timeoutCode || "SSH_CHANNEL_OPEN_TIMEOUT";
|
||||
const invalidateOnAbort = options.invalidateOnAbort !== false;
|
||||
const invalidateOnTimeout = options.invalidateOnTimeout !== false;
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let invoked = false;
|
||||
let abandoned = false;
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error, result, { invalidate = false } = {}) => {
|
||||
const finish = (error, result, { invalidate = false, abandon = false } = {}) => {
|
||||
if (settled) {
|
||||
if (result) closeLateResult(result);
|
||||
if (abandoned) {
|
||||
abandoned = false;
|
||||
try { options.onAbandonedOpenSettled?.(); } catch { /* ignore */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error && result) closeLateResult(result);
|
||||
if (abandon && invoked) {
|
||||
abandoned = true;
|
||||
try { options.onAbandonedOpen?.(); } catch { /* ignore */ }
|
||||
}
|
||||
if (invalidate) invalidateSshTransport(sshClient);
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => finish(channelAbortError(signal, label), null, { invalidate: true });
|
||||
const onAbort = () => finish(
|
||||
channelAbortError(signal, label),
|
||||
null,
|
||||
{ invalidate: invalidateOnAbort, abandon: !invalidateOnAbort },
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
finish(channelAbortError(signal, label));
|
||||
@@ -83,10 +134,14 @@ function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
timer = setTimeoutFn(() => {
|
||||
const error = new Error(`${label} timed out after ${timeoutMs} ms`);
|
||||
error.code = timeoutCode;
|
||||
finish(error, null, { invalidate: true });
|
||||
finish(error, null, {
|
||||
invalidate: invalidateOnTimeout,
|
||||
abandon: !invalidateOnTimeout,
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
invoked = true;
|
||||
invoke((error, result) => finish(error, result));
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
@@ -95,40 +150,96 @@ function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
}
|
||||
|
||||
async function openBoundedSshShell(sshClient, windowOptions, shellOptions, options = {}) {
|
||||
const hasRateLimitRetryTimeout = Number.isFinite(options.rateLimitRetryTimeoutMs);
|
||||
const hasExplicitRateLimitRetries = Number.isFinite(options.rateLimitRetries);
|
||||
const rateLimitRetries = Math.max(
|
||||
0,
|
||||
Number.isFinite(options.rateLimitRetries)
|
||||
hasExplicitRateLimitRetries
|
||||
? Number(options.rateLimitRetries)
|
||||
: DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
|
||||
: hasRateLimitRetryTimeout
|
||||
? Number.POSITIVE_INFINITY
|
||||
: DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
|
||||
);
|
||||
const rateLimitRetryTimeoutMs = hasRateLimitRetryTimeout
|
||||
? Math.max(0, Number(options.rateLimitRetryTimeoutMs))
|
||||
: null;
|
||||
const rateLimitBackoffMs = Math.max(
|
||||
1,
|
||||
Number(options.rateLimitBackoffMs) || DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS,
|
||||
);
|
||||
const sleepFn = options.sleepFn || null;
|
||||
const nowFn = typeof options.nowFn === "function" ? options.nowFn : monotonicNow;
|
||||
const retryStartedAt = nowFn();
|
||||
const retryDeadline = rateLimitRetryTimeoutMs === null
|
||||
? null
|
||||
: retryStartedAt + rateLimitRetryTimeoutMs;
|
||||
let attempt = 0;
|
||||
let lastRateLimitError = null;
|
||||
|
||||
for (;;) {
|
||||
const attemptStartedAt = nowFn();
|
||||
if (
|
||||
lastRateLimitError
|
||||
&& retryDeadline !== null
|
||||
&& attemptStartedAt >= retryDeadline
|
||||
) {
|
||||
throw lastRateLimitError;
|
||||
}
|
||||
const configuredAttemptTimeoutMs = Math.max(
|
||||
1,
|
||||
Number(options.timeoutMs) || DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS,
|
||||
);
|
||||
const isRateLimitRetry = lastRateLimitError !== null;
|
||||
const attemptTimeoutMs = retryDeadline === null || !isRateLimitRetry
|
||||
? configuredAttemptTimeoutMs
|
||||
: Math.max(
|
||||
1,
|
||||
Math.min(configuredAttemptTimeoutMs, Math.ceil(retryDeadline - attemptStartedAt)),
|
||||
);
|
||||
const retryBudgetConstrainsAttempt = isRateLimitRetry
|
||||
&& attemptTimeoutMs < configuredAttemptTimeoutMs;
|
||||
try {
|
||||
return await openBoundedSshChannel(
|
||||
sshClient,
|
||||
(callback) => sshClient.shell(windowOptions, shellOptions, callback),
|
||||
{
|
||||
...options,
|
||||
timeoutMs: attemptTimeoutMs,
|
||||
invalidateOnTimeout: retryBudgetConstrainsAttempt
|
||||
? false
|
||||
: options.invalidateOnTimeout,
|
||||
label: options.label || "SSH shell channel open",
|
||||
timeoutCode: "SSH_SHELL_OPEN_TIMEOUT",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
retryBudgetConstrainsAttempt
|
||||
&& error?.code === "SSH_SHELL_OPEN_TIMEOUT"
|
||||
&& lastRateLimitError
|
||||
) {
|
||||
throw lastRateLimitError;
|
||||
}
|
||||
if (!isSshChannelOpenRateLimitedError(error) || options.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
lastRateLimitError = error;
|
||||
const nextDelayMs = rateLimitBackoffMs * (attempt + 1);
|
||||
const retryTimeoutExpired = rateLimitRetryTimeoutMs !== null
|
||||
&& nowFn() + nextDelayMs >= retryDeadline;
|
||||
if (
|
||||
attempt >= rateLimitRetries
|
||||
|| !isSshChannelOpenRateLimitedError(error)
|
||||
|| options.signal?.aborted
|
||||
|| retryTimeoutExpired
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
attempt += 1;
|
||||
await sleep(rateLimitBackoffMs * attempt, sleepFn);
|
||||
await sleep(
|
||||
nextDelayMs,
|
||||
sleepFn,
|
||||
options.signal,
|
||||
options.label || "SSH shell channel retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ const {
|
||||
|
||||
function trackedTimerApi() {
|
||||
const active = new Set();
|
||||
const delays = [];
|
||||
return {
|
||||
active,
|
||||
delays,
|
||||
setTimeoutFn(callback, delay) {
|
||||
delays.push(delay);
|
||||
let timer;
|
||||
timer = setTimeout(() => {
|
||||
active.delete(timer);
|
||||
@@ -85,6 +88,34 @@ test("cancelled channel open invalidates transport and a late stream is closed",
|
||||
assert.equal(timers.active.size, 0);
|
||||
});
|
||||
|
||||
test("non-invalidating cancellation reports an abandoned open until its callback settles", async () => {
|
||||
let callback;
|
||||
let abandoned = 0;
|
||||
let abandonedSettled = 0;
|
||||
const controller = new AbortController();
|
||||
const client = {
|
||||
shell(_window, _options, next) { callback = next; },
|
||||
};
|
||||
const pending = openBoundedSshShell(client, {}, {}, {
|
||||
signal: controller.signal,
|
||||
invalidateOnAbort: false,
|
||||
onAbandonedOpen: () => { abandoned += 1; },
|
||||
onAbandonedOpenSettled: () => { abandonedSettled += 1; },
|
||||
});
|
||||
|
||||
controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(pending, /cancelled/);
|
||||
assert.equal(abandoned, 1);
|
||||
assert.equal(abandonedSettled, 0);
|
||||
|
||||
const lateStream = new EventEmitter();
|
||||
lateStream.closed = 0;
|
||||
lateStream.close = () => { lateStream.closed += 1; };
|
||||
callback(null, lateStream);
|
||||
assert.equal(lateStream.closed, 1);
|
||||
assert.equal(abandonedSettled, 1);
|
||||
});
|
||||
|
||||
test("channel open keeps its deadline referenced and clears it after success", async () => {
|
||||
let callback;
|
||||
const timers = trackedTimerApi();
|
||||
@@ -107,6 +138,89 @@ test("channel open keeps its deadline referenced and clears it after success", a
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
});
|
||||
|
||||
test("shell open caps each attempt to the remaining rate-limit retry window", async () => {
|
||||
const timers = trackedTimerApi();
|
||||
const controller = new AbortController();
|
||||
let elapsedMs = 0;
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
}
|
||||
},
|
||||
};
|
||||
const pending = openBoundedSshShell(client, {}, {}, {
|
||||
signal: controller.signal,
|
||||
timeoutMs: 1_000,
|
||||
rateLimitRetryTimeoutMs: 5,
|
||||
rateLimitBackoffMs: 1,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async (ms) => { elapsedMs += ms; },
|
||||
...timers,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(attempts, 2);
|
||||
assert.deepEqual(timers.delays, [1_000, 4]);
|
||||
controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(pending, /cancelled/);
|
||||
});
|
||||
|
||||
test("a retry-budget timeout preserves the transport and returns the rate-limit error", async () => {
|
||||
let attempts = 0;
|
||||
let invalidations = 0;
|
||||
let elapsedMs = 0;
|
||||
let lateCallback = null;
|
||||
let abandoned = 0;
|
||||
let abandonedSettled = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
} else {
|
||||
lateCallback = next;
|
||||
}
|
||||
},
|
||||
destroy() { invalidations += 1; },
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
timeoutMs: 1_000,
|
||||
rateLimitRetryTimeoutMs: 5,
|
||||
rateLimitBackoffMs: 1,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async (ms) => { elapsedMs += ms; },
|
||||
setTimeoutFn(callback, ms) {
|
||||
if (ms < 1_000) {
|
||||
elapsedMs += ms;
|
||||
setImmediate(callback);
|
||||
}
|
||||
return { unref() {} };
|
||||
},
|
||||
clearTimeoutFn() {},
|
||||
onAbandonedOpen: () => { abandoned += 1; },
|
||||
onAbandonedOpenSettled: () => { abandonedSettled += 1; },
|
||||
}),
|
||||
/channelOpen too offen/,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(invalidations, 0);
|
||||
assert.equal(abandoned, 1);
|
||||
assert.equal(abandonedSettled, 0);
|
||||
|
||||
const lateStream = new EventEmitter();
|
||||
lateStream.closed = 0;
|
||||
lateStream.close = () => { lateStream.closed += 1; };
|
||||
lateCallback(null, lateStream);
|
||||
assert.equal(lateStream.closed, 1);
|
||||
assert.equal(abandonedSettled, 1);
|
||||
});
|
||||
|
||||
test("detects bastion channelOpen rate-limit errors including the offen typo", () => {
|
||||
assert.equal(
|
||||
isSshChannelOpenRateLimitedError(
|
||||
@@ -151,7 +265,89 @@ test("shell open retries bastion rate-limit failures with short backoff", async
|
||||
assert.deepEqual(delays, [5, 10]);
|
||||
});
|
||||
|
||||
test("shell open can use a bounded retry window for variable bastion cooldowns", async () => {
|
||||
const delays = [];
|
||||
let elapsedMs = 0;
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
if (attempts <= 4) {
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
return;
|
||||
}
|
||||
next(null, new EventEmitter());
|
||||
},
|
||||
};
|
||||
|
||||
const stream = await openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetryTimeoutMs: 11,
|
||||
rateLimitBackoffMs: 1,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async (ms) => {
|
||||
delays.push(ms);
|
||||
elapsedMs += ms;
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(stream);
|
||||
assert.equal(attempts, 5);
|
||||
assert.deepEqual(delays, [1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test("shell open stops retrying when the bastion retry window is exhausted", async () => {
|
||||
const delays = [];
|
||||
let elapsedMs = 0;
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetryTimeoutMs: 5,
|
||||
rateLimitBackoffMs: 2,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async (ms) => {
|
||||
delays.push(ms);
|
||||
elapsedMs += ms;
|
||||
},
|
||||
}),
|
||||
/channelOpen too offen/,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
assert.deepEqual(delays, [2]);
|
||||
});
|
||||
|
||||
test("shell open does not start another attempt after a delayed retry wakes past the deadline", async () => {
|
||||
let elapsedMs = 0;
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetryTimeoutMs: 5,
|
||||
rateLimitBackoffMs: 1,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async () => { elapsedMs = 10; },
|
||||
}),
|
||||
/channelOpen too offen/,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("shell open does not retry unrelated channel open failures", async () => {
|
||||
const delays = [];
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
@@ -162,11 +358,12 @@ test("shell open does not retry unrelated channel open failures", async () => {
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetries: 3,
|
||||
rateLimitRetryTimeoutMs: 10,
|
||||
rateLimitBackoffMs: 5,
|
||||
sleepFn: async () => {},
|
||||
sleepFn: async (ms) => { delays.push(ms); },
|
||||
}),
|
||||
/administratively prohibited/,
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const Module = require("node:module");
|
||||
const { abortPendingBoot } = require("./sessionBootEpoch.cjs");
|
||||
|
||||
const sshConnectionPool = require("./sshConnectionPool.cjs");
|
||||
const {
|
||||
@@ -1302,6 +1303,316 @@ test("Copy Tab retries bastion channelOpen too offen before falling back", async
|
||||
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
|
||||
});
|
||||
|
||||
test("Copy Tab keeps reusing when a bastion rate limit outlasts the legacy retry burst", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
if (shellAttempts <= 4) {
|
||||
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
||||
return;
|
||||
}
|
||||
const stream = makeStream();
|
||||
sourceConn.openedShells.push(stream);
|
||||
setImmediate(() => cb(null, stream));
|
||||
};
|
||||
sessions.set("source", makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
}));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const result = await start(
|
||||
{ sender },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.sessionId, "copy");
|
||||
assert.equal(shellAttempts, 5);
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sourceConn.openedShells.length, 1);
|
||||
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
|
||||
});
|
||||
|
||||
test("ordinary parked reuse keeps the legacy retry bound", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
||||
};
|
||||
sessions.set("source", makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
}));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "ordinary",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
),
|
||||
/unexpected fresh connect/,
|
||||
);
|
||||
|
||||
// The ordinary path can try the same live transport through its parked and
|
||||
// coordinated-reuse stages. Each stage keeps the legacy four-attempt bound.
|
||||
assert.equal(shellAttempts, 8);
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
});
|
||||
|
||||
test("cancelling Copy Tab during rate-limit backoff stops retries and keeps the source alive", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
setImmediate(() => {
|
||||
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
setImmediate(() => abortPendingBoot("copy", 1));
|
||||
});
|
||||
};
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
});
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
sshChannelOpenRateLimitBackoffMs: 50,
|
||||
},
|
||||
),
|
||||
/aborted/,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
|
||||
assert.equal(shellAttempts, 1);
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sourceConn.ended, 0);
|
||||
assert.equal(source.connRef.count, 1);
|
||||
});
|
||||
|
||||
test("cancelling Copy Tab during reused-shell liveness does not register a stale session", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
sourceConn._remoteVer = "CustomBastion_1.0";
|
||||
let openedStream = null;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
openedStream = makeStream();
|
||||
sourceConn.openedShells.push(openedStream);
|
||||
setImmediate(() => {
|
||||
cb(null, openedStream);
|
||||
setTimeout(() => abortPendingBoot("copy", 1), 5);
|
||||
});
|
||||
};
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
});
|
||||
source.connRef.pendingShellReconnectRisk = {
|
||||
oldShellPids: [],
|
||||
hasUnknownOldShell: true,
|
||||
};
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
sshReusedShellLivenessMs: 100,
|
||||
},
|
||||
),
|
||||
/aborted/,
|
||||
);
|
||||
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sessions.has("copy"), false);
|
||||
assert.equal(openedStream.closed, true);
|
||||
assert.equal(sourceConn.ended, 0);
|
||||
assert.equal(source.connRef.count, 1);
|
||||
});
|
||||
|
||||
test("cancelling ordinary parked reuse does not start a fresh login", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
setImmediate(() => {
|
||||
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
setImmediate(() => abortPendingBoot("ordinary", 1));
|
||||
});
|
||||
};
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
});
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "ordinary",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
bootEpoch: 1,
|
||||
sshChannelOpenRateLimitBackoffMs: 50,
|
||||
},
|
||||
),
|
||||
/aborted/,
|
||||
);
|
||||
|
||||
assert.equal(shellAttempts, 1);
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sourceConn.ended, 0);
|
||||
assert.equal(source.connRef.count, 1);
|
||||
});
|
||||
|
||||
test("an abandoned Copy Tab open blocks overlapping reuse until the raw callback settles", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeDeferredShellConn();
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
});
|
||||
sessions.set("source", source);
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
|
||||
const firstStart = start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy-1",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
},
|
||||
);
|
||||
for (let attempt = 0; attempt < 20 && sourceConn._pending.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(sourceConn._pending.length, 1);
|
||||
abortPendingBoot("copy-1", 1);
|
||||
await assert.rejects(firstStart, /aborted/);
|
||||
assert.equal(source.connRef.pendingAbandonedShellOpens, 1);
|
||||
assert.equal(sourceConn._pending.length, 1);
|
||||
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy-2",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
},
|
||||
),
|
||||
/unexpected fresh connect/,
|
||||
);
|
||||
assert.equal(sourceConn._pending.length, 1, "second copy must not overlap the abandoned open");
|
||||
|
||||
sourceConn.flushShell();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(source.connRef.pendingAbandonedShellOpens, undefined);
|
||||
|
||||
const thirdStart = start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy-3",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
skipShellPidDiscovery: true,
|
||||
},
|
||||
);
|
||||
for (let attempt = 0; attempt < 20 && sourceConn._pending.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(sourceConn._pending.length, 1);
|
||||
sourceConn.flushShell();
|
||||
const result = await thirdStart;
|
||||
|
||||
assert.equal(result.sessionId, "copy-3");
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
assert.equal(sourceConn.ended, 0);
|
||||
});
|
||||
|
||||
test("a shared connection error during Copy Tab backoff stops queued retries", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
setImmediate(() => {
|
||||
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
setImmediate(() => sourceConn.emit("error", new Error("transport lost")));
|
||||
});
|
||||
};
|
||||
sessions.set("source", makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
}));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
sshChannelOpenRateLimitBackoffMs: 50,
|
||||
},
|
||||
),
|
||||
/unexpected fresh connect/,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
|
||||
assert.equal(shellAttempts, 1);
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
});
|
||||
|
||||
test("Copy Tab opens the shell before PID discovery so bastion rate limits do not burn the session slot", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
|
||||
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
|
||||
const MAX_SSH_CONNECTION_TIMEOUT_MS = 3600000;
|
||||
const COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Fan out netcatty:exit to the primary contents plus any attach-home owner
|
||||
@@ -712,10 +713,41 @@ function createStartSessionApi(ctx) {
|
||||
// is restored.
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let onConnError = null;
|
||||
let abortOpenedStream = null;
|
||||
const retryAbortController = new AbortController();
|
||||
const pendingBootSignal = options._passphraseSignal || null;
|
||||
const abortRetryFromPendingBoot = () => {
|
||||
retryAbortController.abort(
|
||||
pendingBootSignal?.reason || new Error("SSH session start was cancelled"),
|
||||
);
|
||||
abortOpenedStream?.(
|
||||
pendingBootSignal?.reason || new Error("SSH session start was cancelled"),
|
||||
);
|
||||
};
|
||||
const cleanupConnectionGuard = () => {
|
||||
if (onConnError) conn.removeListener("error", onConnError);
|
||||
};
|
||||
const cleanupPendingBootGuard = () => {
|
||||
abortOpenedStream = null;
|
||||
pendingBootSignal?.removeEventListener?.("abort", abortRetryFromPendingBoot);
|
||||
};
|
||||
const cleanupReuseGuards = () => {
|
||||
cleanupConnectionGuard();
|
||||
cleanupPendingBootGuard();
|
||||
};
|
||||
|
||||
if (pendingBootSignal?.aborted) {
|
||||
abortRetryFromPendingBoot();
|
||||
} else {
|
||||
pendingBootSignal?.addEventListener?.("abort", abortRetryFromPendingBoot, { once: true });
|
||||
}
|
||||
|
||||
const failReuse = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupReuseGuards();
|
||||
retryAbortController.abort(err);
|
||||
// Release the hold we took up-front so the source's reference count is
|
||||
// not leaked when we fall back to a fresh connection.
|
||||
releaseConnectionRef(refHolder);
|
||||
@@ -727,12 +759,15 @@ function createStartSessionApi(ctx) {
|
||||
// connection. Removed once the channel opens so we don't leave a stray
|
||||
// listener on the shared connection (the owner's own error handler stays
|
||||
// responsible thereafter).
|
||||
const onConnError = (connErr) => {
|
||||
onConnError = (connErr) => {
|
||||
failReuse(connErr);
|
||||
};
|
||||
conn.once("error", onConnError);
|
||||
|
||||
if (connRef.allowShellReuse === false) {
|
||||
if (
|
||||
connRef.allowShellReuse === false
|
||||
|| Number(connRef.pendingAbandonedShellOpens) > 0
|
||||
) {
|
||||
conn.removeListener("error", onConnError);
|
||||
failReuse(new Error("Transport is no longer reusable for shells"));
|
||||
return;
|
||||
@@ -749,7 +784,7 @@ function createStartSessionApi(ctx) {
|
||||
},
|
||||
shellOptions,
|
||||
(err, stream) => {
|
||||
conn.removeListener("error", onConnError);
|
||||
cleanupConnectionGuard();
|
||||
if (settled) {
|
||||
// Connection already failed; close any channel that still opened
|
||||
// and drop the hold (failReuse already released, so guard with the
|
||||
@@ -771,7 +806,19 @@ function createStartSessionApi(ctx) {
|
||||
|
||||
sendProgress('connected');
|
||||
|
||||
abortOpenedStream = (abortError) => {
|
||||
try { stream.close(); } catch { /* ignore */ }
|
||||
failReuse(abortError);
|
||||
};
|
||||
if (pendingBootSignal?.aborted) {
|
||||
abortOpenedStream(
|
||||
pendingBootSignal.reason || new Error("SSH session start was cancelled"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finishReusedShellOpen = (prefetchedChunks = []) => {
|
||||
cleanupPendingBootGuard();
|
||||
if (settled) {
|
||||
if (stream) { try { stream.close(); } catch { /* ignore */ } }
|
||||
return;
|
||||
@@ -1022,16 +1069,36 @@ function createStartSessionApi(ctx) {
|
||||
failReuse(livenessErr);
|
||||
});
|
||||
},
|
||||
Number.isFinite(rateLimitBackoffMs) && rateLimitBackoffMs > 0
|
||||
? { rateLimitBackoffMs }
|
||||
: {},
|
||||
{
|
||||
...(Number.isFinite(rateLimitBackoffMs) && rateLimitBackoffMs > 0
|
||||
? { rateLimitBackoffMs }
|
||||
: {}),
|
||||
...(options.sourceSessionId
|
||||
? { rateLimitRetryTimeoutMs: COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS }
|
||||
: {}),
|
||||
signal: retryAbortController.signal,
|
||||
// Cancelling one pending Copy Tab must not destroy the source
|
||||
// tab's shared authenticated transport.
|
||||
invalidateOnAbort: false,
|
||||
onAbandonedOpen: () => {
|
||||
connRef.pendingAbandonedShellOpens =
|
||||
(Number(connRef.pendingAbandonedShellOpens) || 0) + 1;
|
||||
},
|
||||
onAbandonedOpenSettled: () => {
|
||||
const pending = Math.max(
|
||||
0,
|
||||
(Number(connRef.pendingAbandonedShellOpens) || 0) - 1,
|
||||
);
|
||||
if (pending === 0) delete connRef.pendingAbandonedShellOpens;
|
||||
else connRef.pendingAbandonedShellOpens = pending;
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (syncErr) {
|
||||
// ssh2 can throw synchronously (e.g. "Not connected") if the borrowed
|
||||
// transport dropped between findReusableSession and conn.shell(). Make
|
||||
// sure we drop the listener and release the up-front ref so the count
|
||||
// isn't leaked, then fall back to a fresh connection.
|
||||
conn.removeListener("error", onConnError);
|
||||
log("reused shell threw synchronously", { sessionId, hostname: options.hostname, error: syncErr?.message });
|
||||
failReuse(syncErr);
|
||||
}
|
||||
@@ -1127,6 +1194,7 @@ function createStartSessionApi(ctx) {
|
||||
try {
|
||||
return await reuseShellSession(event, options, sourceSession, sessionId, log);
|
||||
} catch (reuseErr) {
|
||||
if (options._passphraseSignal?.aborted) throw reuseErr;
|
||||
log("connection reuse failed, falling back to fresh connection", {
|
||||
sessionId,
|
||||
sourceSessionId: options.sourceSessionId,
|
||||
@@ -1178,6 +1246,11 @@ function createStartSessionApi(ctx) {
|
||||
{ confirmReusedShellLiveness },
|
||||
);
|
||||
} catch (parkErr) {
|
||||
if (options._passphraseSignal?.aborted) {
|
||||
throw options._passphraseSignal.reason instanceof Error
|
||||
? options._passphraseSignal.reason
|
||||
: parkErr;
|
||||
}
|
||||
log("parked transport reuse failed, falling back to fresh connection", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
@@ -1213,6 +1286,11 @@ function createStartSessionApi(ctx) {
|
||||
log,
|
||||
);
|
||||
} catch (coordinationErr) {
|
||||
if (options._passphraseSignal?.aborted) {
|
||||
throw options._passphraseSignal.reason instanceof Error
|
||||
? options._passphraseSignal.reason
|
||||
: coordinationErr;
|
||||
}
|
||||
// A waiter must observe the leader's real failure instead of
|
||||
// immediately starting a second authentication prompt. Existing
|
||||
// transport reuse keeps its historical fresh-dial fallback.
|
||||
@@ -1231,6 +1309,12 @@ function createStartSessionApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options._passphraseSignal?.aborted) {
|
||||
throw options._passphraseSignal.reason instanceof Error
|
||||
? options._passphraseSignal.reason
|
||||
: new Error("SSH session start was cancelled");
|
||||
}
|
||||
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
|
||||
|
||||
@@ -985,83 +985,6 @@ html[data-theme-transition] *::after {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-content: flex-start !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
align-items: center !important;
|
||||
min-height: 2rem !important;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid hsl(var(--border) / 0.6) !important;
|
||||
border-radius: 6px !important;
|
||||
background: hsl(var(--background) / 0.96) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar button,
|
||||
.netcatty-note-markdown-toolbar [role="button"],
|
||||
.netcatty-note-markdown-toolbar [role="combobox"] {
|
||||
color: hsl(var(--muted-foreground)) !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar button:hover,
|
||||
.netcatty-note-markdown-toolbar [role="button"]:hover,
|
||||
.netcatty-note-markdown-toolbar [role="combobox"]:hover,
|
||||
.netcatty-note-markdown-toolbar button[data-state="on"],
|
||||
.netcatty-note-markdown-toolbar button[data-state="open"] {
|
||||
background: hsl(var(--secondary)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar [role="separator"] {
|
||||
flex-shrink: 0;
|
||||
background: hsl(var(--border) / 0.7) !important;
|
||||
}
|
||||
|
||||
@container (max-width: 34rem) {
|
||||
.netcatty-note-markdown-toolbar {
|
||||
gap: 0.125rem !important;
|
||||
row-gap: 0.125rem !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-content: flex-start !important;
|
||||
min-height: 1.9rem !important;
|
||||
padding: 0.125rem !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar button,
|
||||
.netcatty-note-markdown-toolbar [role="button"] {
|
||||
width: 1.75rem !important;
|
||||
min-width: 1.75rem !important;
|
||||
height: 1.75rem !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar [role="combobox"] {
|
||||
min-width: 7.25rem !important;
|
||||
max-width: 8.5rem !important;
|
||||
height: 1.75rem !important;
|
||||
padding: 0 0.45rem !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar [role="separator"] {
|
||||
margin: 0 0.125rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor,
|
||||
.netcatty-mdx-editor .mdxeditor-root-contenteditable {
|
||||
min-height: 100%;
|
||||
@@ -1253,7 +1176,10 @@ html[data-theme-transition] *::after {
|
||||
min-height: calc(100vh - 15rem);
|
||||
padding: 0;
|
||||
color: hsl(var(--foreground) / 0.9);
|
||||
font-size: 15px;
|
||||
/* Note body follows the UI font (Settings > Appearance > Interface Font);
|
||||
the note font tool only controls code block / inline code fonts. */
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--note-font-size, 15px);
|
||||
line-height: 1.75;
|
||||
outline: none;
|
||||
}
|
||||
@@ -1313,6 +1239,30 @@ html[data-theme-transition] *::after {
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ul ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
@@ -1324,8 +1274,11 @@ html[data-theme-transition] *::after {
|
||||
* margin so label text lines up cleanly with the control.
|
||||
*/
|
||||
.netcatty-mdx-content ul:has(> li[role="checkbox"]),
|
||||
.netcatty-mdx-content ul:has(> li[aria-checked]) {
|
||||
list-style: none;
|
||||
.netcatty-mdx-content ul:has(> li[aria-checked]),
|
||||
.netcatty-mdx-content ul:has(> li[class*="_listItemChecked_"]),
|
||||
.netcatty-mdx-content ul:has(> li[class*="_listItemUnchecked_"]) {
|
||||
list-style: none !important;
|
||||
list-style-type: none !important;
|
||||
padding-left: 0.15rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -1397,71 +1350,155 @@ html[data-theme-transition] *::after {
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
padding: 0.12rem 0.3rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.86em;
|
||||
font-family: var(--note-font-family, var(--font-mono));
|
||||
font-size: var(--note-code-font-size, 0.88em);
|
||||
}
|
||||
|
||||
.netcatty-mdx-content pre {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--secondary) / 0.35);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"] {
|
||||
position: relative;
|
||||
margin: 0.85rem 0 1rem;
|
||||
position: relative !important;
|
||||
min-height: 2rem !important;
|
||||
margin: 0.35rem 0 0.65rem;
|
||||
border: 0 !important;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.netcatty-note-code-copy {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
z-index: 20;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.45rem;
|
||||
padding: 0 0.45rem;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--background) / 0.92);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
position: absolute !important;
|
||||
top: 0.2rem !important;
|
||||
right: 0.15rem !important;
|
||||
z-index: 40 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 1.45rem !important;
|
||||
height: 1.45rem !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: transparent !important;
|
||||
color: hsl(var(--muted-foreground)) !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 0;
|
||||
cursor: pointer !important;
|
||||
transition: opacity 0.15s, color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:hover .netcatty-note-code-copy,
|
||||
.netcatty-mdx-editor .cm-editor:hover ~ .netcatty-note-code-copy,
|
||||
.netcatty-mdx-editor pre:hover .netcatty-note-code-copy,
|
||||
.netcatty-mdx-content pre:hover .netcatty-note-code-copy,
|
||||
.netcatty-mdx-editor--preview .netcatty-note-code-copy,
|
||||
.netcatty-note-code-copy[data-copied="true"],
|
||||
.netcatty-note-code-copy:focus-visible {
|
||||
opacity: 1;
|
||||
opacity: 0.85 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.netcatty-note-code-copy:hover {
|
||||
background: hsl(var(--secondary));
|
||||
color: hsl(var(--foreground));
|
||||
.netcatty-note-code-copy:hover,
|
||||
.netcatty-mdx-editor--preview .netcatty-note-code-copy:hover {
|
||||
opacity: 1 !important;
|
||||
background: hsl(var(--secondary)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
|
||||
.netcatty-note-code-copy[data-copied="true"] {
|
||||
color: hsl(var(--foreground));
|
||||
color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] {
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
top: auto !important;
|
||||
z-index: auto !important;
|
||||
gap: 0.125rem;
|
||||
padding: 0.125rem;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
.netcatty-mdx-editor .cm-editor,
|
||||
.netcatty-mdx-editor .cm-content,
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"],
|
||||
.netcatty-mdx-editor pre,
|
||||
.netcatty-mdx-editor code,
|
||||
.netcatty-mdx-editor [class*="_code_"],
|
||||
.netcatty-mdx-editor [class*="code_"],
|
||||
.netcatty-mdx-editor kbd,
|
||||
.netcatty-mdx-editor samp,
|
||||
.netcatty-mdx-content pre,
|
||||
.netcatty-mdx-content code,
|
||||
.netcatty-mdx-content [class*="_code_"],
|
||||
.netcatty-mdx-content [class*="code_"],
|
||||
.netcatty-mdx-content kbd,
|
||||
.netcatty-mdx-content samp,
|
||||
.netcatty-source-editor,
|
||||
.netcatty-source-editor textarea {
|
||||
font-family: var(--note-font-family, var(--font-mono)) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor .cm-editor,
|
||||
.netcatty-mdx-editor .cm-content,
|
||||
.netcatty-mdx-editor pre,
|
||||
.netcatty-mdx-content pre,
|
||||
.netcatty-source-editor textarea {
|
||||
font-size: var(--note-code-font-size, 13px) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:has([data-language="latex"]),
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:has([data-language="tex"]) {
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-math-formula-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: safe center;
|
||||
padding: 0.55rem 0;
|
||||
margin: 0.4rem 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 1.45em;
|
||||
color: hsl(var(--foreground));
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
user-select: text;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.netcatty-math-formula-preview .katex-display {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.netcatty-math-formula-preview .katex {
|
||||
font-size: 1em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.netcatty-math-formula-preview .katex-mathml math {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.netcatty-math-reading-mode .cm-editor {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.netcatty-math-reading-mode {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0.4rem 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-math-reading-mode .netcatty-math-formula-preview {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.netcatty-math-reading-mode > .netcatty-note-code-copy {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"] {
|
||||
@@ -1469,10 +1506,10 @@ html[data-theme-transition] *::after {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin: 0.3rem 0 0.65rem;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--secondary) / 0.35) !important;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 0.55rem;
|
||||
border-radius: 0;
|
||||
background: transparent !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) .netcatty-mdx-content p:has(+ [class*="_codeMirrorWrapper_"]) {
|
||||
@@ -1480,6 +1517,7 @@ html[data-theme-transition] *::after {
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"] .cm-editor {
|
||||
min-height: 2rem !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
@@ -1488,65 +1526,82 @@ html[data-theme-transition] *::after {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
z-index: auto;
|
||||
display: flex !important;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
gap: 0.125rem;
|
||||
padding: 0.15rem 0.3rem 0 !important;
|
||||
margin: 0 !important;
|
||||
width: auto !important;
|
||||
background: transparent !important;
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"] .cm-scroller {
|
||||
padding-right: 4.8rem !important;
|
||||
min-height: 2.2rem !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor--preview [class*="_codeMirrorToolbar_"] {
|
||||
display: none !important;
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] {
|
||||
position: absolute !important;
|
||||
top: 0.35rem !important;
|
||||
right: 0.35rem !important;
|
||||
z-index: 30 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem !important;
|
||||
padding: 0.2rem 0.25rem !important;
|
||||
margin: 0 !important;
|
||||
width: auto !important;
|
||||
border-radius: 6px !important;
|
||||
background: hsl(var(--background) / 0.94) !important;
|
||||
border: 1px solid hsl(var(--border) / 0.65) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.18) !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
transition: opacity 0.15s ease !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"]:hover [class*="_codeMirrorToolbar_"],
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeMirrorToolbar_"],
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"]:has([data-state="open"]) {
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Language selector trigger in toolbar */
|
||||
/* Align the TooltipWrap span wrapper so the language select sits level with action buttons */
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_tooltipTrigger_"] {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
align-self: center !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"],
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
height: 1.45rem !important;
|
||||
min-height: 1.45rem !important;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
padding: 0 0.4rem !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"],
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start;
|
||||
height: 1.35rem !important;
|
||||
width: auto !important;
|
||||
min-width: fit-content !important;
|
||||
max-width: none !important;
|
||||
padding: 0 0.35rem !important;
|
||||
padding: 0 0.45rem !important;
|
||||
margin: 0 !important;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 400 !important;
|
||||
font-weight: 500 !important;
|
||||
line-height: 1 !important;
|
||||
white-space: nowrap;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: hsl(var(--background) / 0.88) !important;
|
||||
background: hsl(var(--muted) / 0.7) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease, color 0.15s ease !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] [data-radix-select-value],
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] > span:first-child {
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"]:hover,
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"]:hover {
|
||||
background: hsl(var(--secondary)) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] [data-radix-select-value],
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] > span:first-child {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
@@ -1555,48 +1610,66 @@ html[data-theme-transition] *::after {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"][data-state="open"] {
|
||||
border-radius: 4px !important;
|
||||
background: hsl(var(--background) / 0.88) !important;
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectDropdownArrow_"] {
|
||||
margin-left: 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"] svg,
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] svg {
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectDropdownArrow_"] svg,
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"] svg,
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] svg {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] button {
|
||||
min-height: 1.45rem !important;
|
||||
height: 1.45rem !important;
|
||||
min-width: 1.45rem !important;
|
||||
width: auto !important;
|
||||
padding: 0 0.35rem !important;
|
||||
background: hsl(var(--background) / 0.88) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] button {
|
||||
min-height: 1.35rem !important;
|
||||
height: 1.35rem !important;
|
||||
min-width: 1.35rem !important;
|
||||
width: 1.35rem !important;
|
||||
padding: 0 !important;
|
||||
/* Base style for all action buttons (Delete & Copy) in toolbar */
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] button:not([class*="_selectTrigger_"]):not([class*="_toolbarCodeBlockLanguageSelectTrigger_"]) {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
height: 1.4rem !important;
|
||||
min-height: 1.4rem !important;
|
||||
max-height: 1.4rem !important;
|
||||
width: 1.4rem !important;
|
||||
min-width: 1.4rem !important;
|
||||
max-width: 1.4rem !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: transparent !important;
|
||||
color: hsl(var(--muted-foreground)) !important;
|
||||
box-shadow: none !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease, color 0.15s ease !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] button svg {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
/* Delete Button specific hover */
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] button:not([class*="_selectTrigger_"]):not([class*="_toolbarCodeBlockLanguageSelectTrigger_"]):not(.netcatty-note-code-copy):hover {
|
||||
background: hsl(var(--destructive) / 0.15) !important;
|
||||
color: hsl(var(--destructive)) !important;
|
||||
}
|
||||
|
||||
/* Copy Button inside toolbar */
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] .netcatty-note-code-copy {
|
||||
position: static !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] .netcatty-note-code-copy:hover {
|
||||
background: hsl(160 84% 39% / 0.15) !important;
|
||||
color: hsl(160 84% 39%) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] button svg {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"]:has([class*="_codeMirrorToolbar_"]) > .netcatty-note-code-copy {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor--preview [class*="_codeMirrorToolbar_"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"] span {
|
||||
@@ -1671,8 +1744,8 @@ html[data-theme-transition] *::after {
|
||||
--note-code-token-string: color-mix(in oklab, hsl(var(--primary)) 24%, hsl(var(--foreground)));
|
||||
overflow: hidden;
|
||||
border: 0 !important;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--secondary) / 0.35) !important;
|
||||
border-radius: 0;
|
||||
background: transparent !important;
|
||||
color: hsl(var(--foreground) / 0.9) !important;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px !important;
|
||||
@@ -1688,7 +1761,7 @@ html[data-theme-transition] *::after {
|
||||
background: transparent !important;
|
||||
color: hsl(var(--foreground) / 0.9) !important;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.25rem 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor .cm-line {
|
||||
@@ -1739,7 +1812,7 @@ html[data-theme-transition] *::after {
|
||||
|
||||
.netcatty-mdx-editor .cm-gutters {
|
||||
border-right: 1px solid hsl(var(--border) / 0.35) !important;
|
||||
background: hsl(var(--secondary) / 0.24) !important;
|
||||
background: transparent !important;
|
||||
color: hsl(var(--muted-foreground) / 0.75) !important;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px !important;
|
||||
@@ -1882,16 +1955,47 @@ html[data-theme-transition] *::after {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Small-icon chrome: trash/settings only on hover. */
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"][data-note-img-size="sm"] [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"][data-note-img-size="sm"] [class*="_editImageToolbar_"] {
|
||||
/* Image chrome stays out of the image until the image is hovered or focused. */
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"] [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"] [class*="_editImageToolbar_"] {
|
||||
top: 0.375rem;
|
||||
right: 0.375rem;
|
||||
gap: 0.0625rem;
|
||||
padding: 0.125rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--background) / 0.96);
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.12);
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"][data-note-img-size="sm"]:hover [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"][data-note-img-size="sm"]:hover [class*="_editImageToolbar_"] {
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"] [class*="_editImageToolbar_"] button,
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"] [class*="_editImageToolbar_"] button {
|
||||
display: inline-flex;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"] [class*="_editImageToolbar_"] button svg,
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"] [class*="_editImageToolbar_"] button svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"] [class*="_editImageToolbar_"] button:hover,
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"] [class*="_editImageToolbar_"] button:hover {
|
||||
background: hsl(var(--secondary) / 0.75);
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"]:hover [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"]:focus-within [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"]:hover [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"]:focus-within [class*="_editImageToolbar_"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ export const STORAGE_KEY_VAULT_NOTES_VIEW_MODE = 'netcatty_vault_notes_view_mode
|
||||
export const STORAGE_KEY_VAULT_NOTES_EDITOR_MODE = 'netcatty_vault_notes_editor_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_SELECTED_GROUP = 'netcatty_vault_notes_selected_group_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_TREE_WIDTH = 'netcatty_vault_notes_tree_width_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_FONT_FAMILY = 'netcatty_vault_notes_font_family_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_FONT_SIZE = 'netcatty_vault_notes_font_size_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE = 'netcatty_vault_notes_code_font_size_v1';
|
||||
/** Inline snippet/script edit panel width (px). */
|
||||
export const STORAGE_KEY_SNIPPETS_PANEL_WIDTH = 'netcatty_snippets_panel_width_v1';
|
||||
/** Inline vault host/group details panel width (px). */
|
||||
|
||||
Generated
+409
@@ -59,7 +59,12 @@
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"katex": "^0.18.4",
|
||||
"lucide-react": "0.560.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "1.1.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
@@ -102,6 +107,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
"sass": "^1.103.1",
|
||||
"simple-icons": "^16.23.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -4743,6 +4749,326 @@
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
|
||||
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher-android-arm64": "2.6.0",
|
||||
"@parcel/watcher-darwin-arm64": "2.6.0",
|
||||
"@parcel/watcher-darwin-x64": "2.6.0",
|
||||
"@parcel/watcher-freebsd-x64": "2.6.0",
|
||||
"@parcel/watcher-linux-arm-glibc": "2.6.0",
|
||||
"@parcel/watcher-linux-arm-musl": "2.6.0",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.6.0",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.6.0",
|
||||
"@parcel/watcher-linux-x64-musl": "2.6.0",
|
||||
"@parcel/watcher-win32-arm64": "2.6.0",
|
||||
"@parcel/watcher-win32-x64": "2.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
|
||||
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
|
||||
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
|
||||
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
|
||||
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
|
||||
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
|
||||
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
|
||||
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
|
||||
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
|
||||
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
|
||||
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
|
||||
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
|
||||
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
|
||||
@@ -10150,6 +10476,22 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -13027,6 +13369,13 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
|
||||
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -13595,6 +13944,31 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.18.4",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.4.tgz",
|
||||
"integrity": "sha512-IMPntbRLOU+eu88XDiFKqQ8Akhr9Tv7jDMXqPhjG9SI1JMA4DIgXk4x9k4skJz2NZJXBRbC+2pYBLj9olqcZow==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -17048,6 +17422,20 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||
@@ -17455,6 +17843,27 @@
|
||||
"truncate-utf8-bytes": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.103.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.103.1.tgz",
|
||||
"integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||
|
||||
@@ -115,7 +115,12 @@
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"katex": "^0.18.4",
|
||||
"lucide-react": "0.560.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "1.1.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
@@ -155,6 +160,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
"sass": "^1.103.1",
|
||||
"simple-icons": "^16.23.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const config = require("../electron-builder.config.cjs");
|
||||
|
||||
@@ -185,6 +187,7 @@ test("build.files excludes Vite-bundled renderer-only packages", () => {
|
||||
"!node_modules/lexical/**/*",
|
||||
"!node_modules/@lexical/**/*",
|
||||
"!node_modules/@codemirror/**/*",
|
||||
"!node_modules/katex/**/*",
|
||||
"!node_modules/shiki/**/*",
|
||||
"!node_modules/@shiki/**/*",
|
||||
]) {
|
||||
@@ -195,6 +198,14 @@ test("build.files excludes Vite-bundled renderer-only packages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("KaTeX distribution keeps its license notice", () => {
|
||||
const notice = readFileSync(
|
||||
path.join(__dirname, "..", "public", "licenses", "KaTeX-LICENSE.txt"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(notice, /Copyright \(c\) 2013-2020 Khan Academy and other contributors/);
|
||||
});
|
||||
|
||||
test("linux packaging uses multi-size build/icons instead of a single 1024px override", async () => {
|
||||
assert.equal(
|
||||
config.linux.icon,
|
||||
|
||||
@@ -74,6 +74,7 @@ if (!process.versions.electron) {
|
||||
}).outputFiles[0].text;
|
||||
|
||||
const result = await window.webContents.executeJavaScript(`(async () => {
|
||||
try {
|
||||
const { Terminal } = require(${JSON.stringify(xtermPath)});
|
||||
const { WebglAddon } = require(${JSON.stringify(webglPath)});
|
||||
const { SerializeAddon } = require(${JSON.stringify(serializePath)});
|
||||
@@ -105,8 +106,64 @@ if (!process.versions.electron) {
|
||||
}];
|
||||
const blueRules = redRules.map(rule => ({ ...rule, color: "#60A5FA" }));
|
||||
highlighter.setRules(redRules, true);
|
||||
const originalRecolorRange = highlighter.recolorRange.bind(highlighter);
|
||||
let measuredRecolorRows = 0;
|
||||
let measuredRefreshes = 0;
|
||||
highlighter.recolorRange = (startY, endY, refresh, force) => {
|
||||
measuredRecolorRows += Math.abs(endY - startY) + 1;
|
||||
if (refresh) measuredRefreshes += 1;
|
||||
return originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
const write = data => new Promise(resolve => term.write(data, resolve));
|
||||
const waitPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
await write("row-1 ERROR\\r\\nrow-2 ERROR\\r\\nrow-3 ERROR\\r\\nrow-4 ERROR");
|
||||
await write("\\x1b[1;");
|
||||
await write(
|
||||
"1Hrow-2 ERROR\\x1b[2;1Hrow-3 ERROR"
|
||||
+ "\\x1b[3;1Hrow-4 ERROR\\x1b[4;1Hprompt ER",
|
||||
);
|
||||
await write("ROR\\x1b[1;1H");
|
||||
await waitPaint();
|
||||
const moshFrame = serializer.serialize({ scrollback: 0 });
|
||||
const moshHighlightCount = (moshFrame.match(/38;2;248;113;113m/g) || []).length;
|
||||
term.reset();
|
||||
|
||||
const moshRows = 40;
|
||||
const seedMoshRows = async () => {
|
||||
await write(Array.from(
|
||||
{ length: moshRows },
|
||||
(_, index) => "old-" + (index + 1) + " ERROR",
|
||||
).join("\\r\\n"));
|
||||
};
|
||||
const rowFrames = Array.from(
|
||||
{ length: moshRows },
|
||||
(_, index) => "\\x1b[" + (index + 1) + ";1Hnew-" + (index + 1)
|
||||
+ " ERROR\\x1b[1;1H",
|
||||
);
|
||||
await seedMoshRows();
|
||||
measuredRecolorRows = 0;
|
||||
measuredRefreshes = 0;
|
||||
const mergedMoshStart = performance.now();
|
||||
await write(rowFrames.join(""));
|
||||
await waitPaint();
|
||||
const mergedMoshMs = performance.now() - mergedMoshStart;
|
||||
const mergedMoshRecolorRows = measuredRecolorRows;
|
||||
const mergedMoshRefreshes = measuredRefreshes;
|
||||
term.reset();
|
||||
await seedMoshRows();
|
||||
measuredRecolorRows = 0;
|
||||
measuredRefreshes = 0;
|
||||
const splitMoshStart = performance.now();
|
||||
for (const rowFrame of rowFrames) await write(rowFrame);
|
||||
await waitPaint();
|
||||
const splitMoshMs = performance.now() - splitMoshStart;
|
||||
const splitMoshRecolorRows = measuredRecolorRows;
|
||||
const splitMoshRefreshes = measuredRefreshes;
|
||||
const splitMoshFrame = serializer.serialize({ scrollback: 0 });
|
||||
const splitMoshHighlightCount = (splitMoshFrame.match(/38;2;248;113;113m/g) || []).length;
|
||||
term.reset();
|
||||
|
||||
let history = "";
|
||||
for (let line = 0; line < 10000; line += 1) {
|
||||
history += "2026-08-13 worker=" + (line % 32) + " ERROR failed from 10.2." + (line % 255) + "." + ((line * 7) % 255) + "\\r\\n";
|
||||
@@ -141,6 +198,14 @@ if (!process.versions.electron) {
|
||||
const pristine = highlighter.serializeAddon.serialize({ scrollback: 10000 });
|
||||
const state = {
|
||||
renderer,
|
||||
moshHighlightCount,
|
||||
mergedMoshMs,
|
||||
mergedMoshRecolorRows,
|
||||
mergedMoshRefreshes,
|
||||
splitMoshMs,
|
||||
splitMoshRecolorRows,
|
||||
splitMoshRefreshes,
|
||||
splitMoshHighlightCount,
|
||||
rawChars: history.length,
|
||||
initialWriteMs,
|
||||
rebuildsBeforeEnter,
|
||||
@@ -154,11 +219,20 @@ if (!process.versions.electron) {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
return state;
|
||||
} catch (error) {
|
||||
return { executionError: String(error && (error.stack || error)) };
|
||||
}
|
||||
})()`);
|
||||
|
||||
assert.equal(result.executionError, undefined, JSON.stringify(result));
|
||||
if (process.env.NETCATTY_TERMINAL_PERF_REQUIRE_WEBGL === "1") {
|
||||
assert.equal(result.renderer, "webgl", JSON.stringify(result));
|
||||
}
|
||||
assert.ok(result.moshHighlightCount >= 4, JSON.stringify(result));
|
||||
assert.ok(result.splitMoshHighlightCount >= 40, JSON.stringify(result));
|
||||
assert.ok(result.splitMoshRecolorRows <= 80, JSON.stringify(result));
|
||||
assert.ok(result.splitMoshRefreshes <= 80, JSON.stringify(result));
|
||||
assert.ok(result.splitMoshMs < 500, `split Mosh repaint regressed: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.enterRebuildCount, result.rebuildsBeforeEnter, JSON.stringify(result));
|
||||
assert.equal(result.rebuildCount, result.rebuildsBeforeEnter + 1, JSON.stringify(result));
|
||||
assert.ok(result.blueMatchCount >= 10000, JSON.stringify(result));
|
||||
|
||||
Reference in new issue
Block a user