mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
506dcab734 | ||
|
|
4b8803369f | ||
|
|
ef4caf8b08 | ||
|
|
ebd77e43bb | ||
|
|
f63be66ede | ||
|
|
6a7c883337 | ||
|
|
47dcbf3a41 | ||
|
|
7c371372a3 | ||
|
|
99e9879c9a | ||
|
|
b073162eaf | ||
|
|
f26bd5fa68 | ||
|
|
8184a1361d | ||
|
|
df5c033dfa | ||
|
|
b5fee9632e | ||
|
|
fb69f4a48d | ||
|
|
1d8cba97ae |
No files matched your search
@@ -538,6 +538,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': 'Search titles and recent content…',
|
||||
'ai.chat.clearSessionSearch': 'Clear session search',
|
||||
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
|
||||
'ai.chat.searchingSessions': 'Searching...',
|
||||
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.forkFromHere': 'Fork from here',
|
||||
|
||||
@@ -24,7 +24,7 @@ export const enMonitorMessages = {
|
||||
"monitor.stopAll": "Stop all",
|
||||
"monitor.list": "List",
|
||||
"monitor.charts": "Charts",
|
||||
"monitor.search": "Search name, address or group",
|
||||
"monitor.search": "Search hosts, groups, tags or notes",
|
||||
"monitor.interval": "Update interval",
|
||||
"monitor.range": "Trend range",
|
||||
"monitor.seconds": "Every {count}s",
|
||||
@@ -34,6 +34,13 @@ export const enMonitorMessages = {
|
||||
"monitor.sort.name": "Name",
|
||||
"monitor.selectedCount": "Selected hosts: {count}",
|
||||
"monitor.noMatches": "No matching hosts",
|
||||
"monitor.latency": "Latency",
|
||||
"monitor.responseLatency": "Response latency (includes remote processing)",
|
||||
"monitor.tags": "Tags",
|
||||
"monitor.notes": "Notes",
|
||||
"monitor.visibleColumns": "Visible columns",
|
||||
"monitor.terminal": "Terminal",
|
||||
"monitor.resizeColumn": "Resize {column} column",
|
||||
"monitor.host": "Host",
|
||||
"monitor.hosts": "Hosts",
|
||||
"monitor.status": "Status",
|
||||
@@ -53,8 +60,6 @@ export const enMonitorMessages = {
|
||||
"monitor.memoryTrend": "Memory trend",
|
||||
"monitor.downloadTrend": "Download trend",
|
||||
"monitor.uploadTrend": "Upload trend",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
|
||||
"monitor.name": "Panel name",
|
||||
"monitor.description": "Description (optional)",
|
||||
"monitor.chooseHosts": "Choose hosts",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const enScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.sidePanel.viewList': 'List view',
|
||||
'scripts.sidePanel.viewStacked': 'Stacked view',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
|
||||
@@ -727,6 +727,7 @@ export const enTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Search scripts...',
|
||||
'snippets.action.new': 'New',
|
||||
'snippets.action.newSnippet': 'New Snippet',
|
||||
'snippets.action.newPackage': 'New Script Package',
|
||||
'snippets.action.import': 'Import',
|
||||
@@ -942,4 +943,69 @@ export const enTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': 'Save and close',
|
||||
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
|
||||
|
||||
'terminal.browser.newTab': 'New tab',
|
||||
'terminal.browser.address': 'Web address',
|
||||
'terminal.browser.back': 'Back',
|
||||
'terminal.browser.forward': 'Forward',
|
||||
'terminal.browser.reload': 'Reload',
|
||||
'terminal.browser.stop': 'Stop',
|
||||
'terminal.browser.bookmarks': 'Apps & bookmarks',
|
||||
'terminal.browser.downloads': 'Downloads',
|
||||
'terminal.browser.empty': 'Save an app or enter an address to get started.',
|
||||
'terminal.browser.connecting': 'Connecting…',
|
||||
'terminal.browser.disconnected': 'Connection unavailable',
|
||||
'terminal.browser.retry': 'Reconnect',
|
||||
'terminal.browser.addBookmark': 'Add app',
|
||||
'terminal.browser.editBookmark': 'Edit app',
|
||||
'terminal.browser.bookmarkTitle': 'Title',
|
||||
'terminal.browser.icon': 'Icon',
|
||||
'terminal.browser.website': 'Website',
|
||||
'terminal.browser.text': 'Text',
|
||||
'terminal.browser.image': 'Image',
|
||||
'terminal.browser.chooseImage': 'Choose image',
|
||||
'terminal.browser.color': 'Color',
|
||||
'terminal.browser.scope': 'Show on',
|
||||
'terminal.browser.hostScope': 'This host',
|
||||
'terminal.browser.allScope': 'All hosts',
|
||||
'terminal.browser.changeIcon': 'Change icon',
|
||||
'terminal.browser.resetIcon': 'Use website icon',
|
||||
'terminal.browser.downloadEmpty': 'No downloads yet',
|
||||
'terminal.browser.openFolder': 'Open downloads folder',
|
||||
'terminal.browser.clearDownloads': 'Clear finished downloads',
|
||||
'terminal.browser.cancelDownload': 'Cancel download',
|
||||
'terminal.browser.reveal': 'Show in folder',
|
||||
'terminal.browser.openFile': 'Open file',
|
||||
'terminal.browser.removeDownload': 'Remove from list',
|
||||
'terminal.browser.waiting': 'Preparing download…',
|
||||
'terminal.browser.progressing': 'Downloading',
|
||||
'terminal.browser.completed': 'Completed',
|
||||
'terminal.browser.cancelled': 'Cancelled',
|
||||
'terminal.browser.interrupted': 'Interrupted',
|
||||
'terminal.browser.missing': 'File moved or deleted',
|
||||
'terminal.browser.closeTab': 'Close tab',
|
||||
'terminal.browser.more': 'More actions',
|
||||
'terminal.browser.websiteIconHint': 'The website icon appears automatically after you visit it.',
|
||||
'terminal.browser.iconText': 'Icon text',
|
||||
'terminal.browser.iconTextHint': 'Use a letter, short name or emoji.',
|
||||
'terminal.browser.selectColor': 'Use {color}',
|
||||
'terminal.browser.customColor': 'Custom color',
|
||||
'terminal.browser.iconPreview': 'Icon preview',
|
||||
'terminal.browser.savedImage': 'Saved image',
|
||||
'terminal.browser.processingImage': 'Preparing image…',
|
||||
'terminal.browser.replaceImage': 'Replace image',
|
||||
'terminal.browser.removeImage': 'Remove image',
|
||||
'terminal.browser.uploadImage': 'Choose an image',
|
||||
'terminal.browser.dropImage': 'or drag and drop it here',
|
||||
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF or ICO · up to 5 MB. Resized automatically.',
|
||||
'terminal.browser.iconError.file-type': 'Choose a PNG, JPG, WebP, GIF or ICO image.',
|
||||
'terminal.browser.iconError.file-size': 'This image is too large. Choose a file under 5 MB.',
|
||||
'terminal.browser.iconError.decode': 'This image could not be read. Try another file.',
|
||||
'terminal.browser.bookmarkPreviewHint': 'Shown on your browser start page',
|
||||
'terminal.browser.untitledBookmark': 'Your app',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': 'e.g. Team docs',
|
||||
'terminal.browser.websiteAddress': 'Website address',
|
||||
'terminal.browser.hostScopeHint': 'Show only in this host’s browser.',
|
||||
'terminal.browser.allScopeHint': 'Show in every host’s browser.',
|
||||
'terminal.browser.downloadLocation': 'Saved to your Downloads folder',
|
||||
'terminal.browser.saveAs': 'Save as…',
|
||||
};
|
||||
@@ -529,6 +529,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…',
|
||||
'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones',
|
||||
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
|
||||
'ai.chat.searchingSessions': 'Buscando...',
|
||||
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
|
||||
|
||||
@@ -24,7 +24,7 @@ export const esMonitorMessages = {
|
||||
"monitor.stopAll": "Detener todo",
|
||||
"monitor.list": "Lista",
|
||||
"monitor.charts": "Gráficos",
|
||||
"monitor.search": "Buscar nombre, dirección o grupo",
|
||||
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
|
||||
"monitor.interval": "Intervalo de actualización",
|
||||
"monitor.range": "Rango de tendencias",
|
||||
"monitor.seconds": "Cada {count} s",
|
||||
@@ -34,6 +34,13 @@ export const esMonitorMessages = {
|
||||
"monitor.sort.name": "Nombre",
|
||||
"monitor.selectedCount": "Equipos seleccionados: {count}",
|
||||
"monitor.noMatches": "No hay equipos coincidentes",
|
||||
"monitor.latency": "Latencia",
|
||||
"monitor.responseLatency": "Latencia de respuesta (incluye procesamiento remoto)",
|
||||
"monitor.tags": "Etiquetas",
|
||||
"monitor.notes": "Notas",
|
||||
"monitor.visibleColumns": "Columnas visibles",
|
||||
"monitor.terminal": "Terminal",
|
||||
"monitor.resizeColumn": "Ajustar la columna {column}",
|
||||
"monitor.host": "Equipo",
|
||||
"monitor.hosts": "Equipos",
|
||||
"monitor.status": "Estado",
|
||||
@@ -53,8 +60,6 @@ export const esMonitorMessages = {
|
||||
"monitor.memoryTrend": "Tendencia de memoria",
|
||||
"monitor.downloadTrend": "Tendencia de descarga",
|
||||
"monitor.uploadTrend": "Tendencia de subida",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
|
||||
"monitor.name": "Nombre del panel",
|
||||
"monitor.description": "Descripción (opcional)",
|
||||
"monitor.chooseHosts": "Elegir equipos",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const esScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.sidePanel.viewList': 'Vista de lista',
|
||||
'scripts.sidePanel.viewStacked': 'Vista apilada',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
|
||||
@@ -689,6 +689,7 @@ export const esTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Buscar scripts...',
|
||||
'snippets.action.new': 'Nuevo',
|
||||
'snippets.action.newSnippet': 'Nuevo snippet',
|
||||
'snippets.action.newPackage': 'Nuevo paquete de scripts',
|
||||
'snippets.action.import': 'Importar',
|
||||
@@ -904,4 +905,69 @@ export const esTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': 'Guardar y cerrar',
|
||||
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
|
||||
|
||||
'terminal.browser.newTab': 'Nueva pestaña',
|
||||
'terminal.browser.address': 'Dirección web',
|
||||
'terminal.browser.back': 'Atrás',
|
||||
'terminal.browser.forward': 'Adelante',
|
||||
'terminal.browser.reload': 'Recargar',
|
||||
'terminal.browser.stop': 'Detener',
|
||||
'terminal.browser.bookmarks': 'Aplicaciones y marcadores',
|
||||
'terminal.browser.downloads': 'Descargas',
|
||||
'terminal.browser.empty': 'Añade una aplicación o introduce una dirección.',
|
||||
'terminal.browser.connecting': 'Conectando…',
|
||||
'terminal.browser.disconnected': 'Conexión no disponible',
|
||||
'terminal.browser.retry': 'Reconectar',
|
||||
'terminal.browser.addBookmark': 'Añadir aplicación',
|
||||
'terminal.browser.editBookmark': 'Editar aplicación',
|
||||
'terminal.browser.bookmarkTitle': 'Título',
|
||||
'terminal.browser.icon': 'Icono',
|
||||
'terminal.browser.website': 'Sitio web',
|
||||
'terminal.browser.text': 'Texto',
|
||||
'terminal.browser.image': 'Imagen',
|
||||
'terminal.browser.chooseImage': 'Elegir imagen',
|
||||
'terminal.browser.color': 'Color',
|
||||
'terminal.browser.scope': 'Mostrar en',
|
||||
'terminal.browser.hostScope': 'Este equipo',
|
||||
'terminal.browser.allScope': 'Todos los equipos',
|
||||
'terminal.browser.changeIcon': 'Cambiar icono',
|
||||
'terminal.browser.resetIcon': 'Usar icono del sitio',
|
||||
'terminal.browser.downloadEmpty': 'No hay descargas',
|
||||
'terminal.browser.openFolder': 'Abrir carpeta de descargas',
|
||||
'terminal.browser.clearDownloads': 'Borrar descargas finalizadas',
|
||||
'terminal.browser.cancelDownload': 'Cancelar descarga',
|
||||
'terminal.browser.reveal': 'Mostrar en carpeta',
|
||||
'terminal.browser.openFile': 'Abrir archivo',
|
||||
'terminal.browser.removeDownload': 'Quitar de la lista',
|
||||
'terminal.browser.waiting': 'Preparando descarga…',
|
||||
'terminal.browser.progressing': 'Descargando',
|
||||
'terminal.browser.completed': 'Completada',
|
||||
'terminal.browser.cancelled': 'Cancelada',
|
||||
'terminal.browser.interrupted': 'Interrumpida',
|
||||
'terminal.browser.missing': 'Archivo movido o eliminado',
|
||||
'terminal.browser.closeTab': 'Cerrar pestaña',
|
||||
'terminal.browser.more': 'Más acciones',
|
||||
'terminal.browser.websiteIconHint': 'El icono se obtiene automáticamente al visitar el sitio.',
|
||||
'terminal.browser.iconText': 'Texto del icono',
|
||||
'terminal.browser.iconTextHint': 'Usa una letra, abreviatura o emoji.',
|
||||
'terminal.browser.selectColor': 'Usar {color}',
|
||||
'terminal.browser.customColor': 'Color personalizado',
|
||||
'terminal.browser.iconPreview': 'Vista previa del icono',
|
||||
'terminal.browser.savedImage': 'Imagen guardada',
|
||||
'terminal.browser.processingImage': 'Preparando imagen…',
|
||||
'terminal.browser.replaceImage': 'Cambiar imagen',
|
||||
'terminal.browser.removeImage': 'Quitar imagen',
|
||||
'terminal.browser.uploadImage': 'Elegir imagen',
|
||||
'terminal.browser.dropImage': 'o arrástrala aquí',
|
||||
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF o ICO · hasta 5 MB. Se ajusta automáticamente.',
|
||||
'terminal.browser.iconError.file-type': 'Elige una imagen PNG, JPG, WebP, GIF o ICO.',
|
||||
'terminal.browser.iconError.file-size': 'La imagen es demasiado grande. Elige un archivo de hasta 5 MB.',
|
||||
'terminal.browser.iconError.decode': 'No se pudo leer la imagen. Prueba con otro archivo.',
|
||||
'terminal.browser.bookmarkPreviewHint': 'Aparece en la página de inicio del navegador',
|
||||
'terminal.browser.untitledBookmark': 'Tu aplicación',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': 'p. ej., Documentos del equipo',
|
||||
'terminal.browser.websiteAddress': 'Dirección del sitio',
|
||||
'terminal.browser.hostScopeHint': 'Mostrar solo en el navegador de este host.',
|
||||
'terminal.browser.allScopeHint': 'Mostrar en el navegador de todos los hosts.',
|
||||
'terminal.browser.downloadLocation': 'Guardado en la carpeta Descargas',
|
||||
'terminal.browser.saveAs': 'Guardar como…',
|
||||
};
|
||||
@@ -485,6 +485,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
|
||||
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
|
||||
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
|
||||
'ai.chat.searchingSessions': 'Поиск...',
|
||||
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.forkFromHere': 'Ветвление отсюда',
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ruMonitorMessages = {
|
||||
"monitor.stopAll": "Остановить всё",
|
||||
"monitor.list": "Список",
|
||||
"monitor.charts": "Графики",
|
||||
"monitor.search": "Имя, адрес или группа",
|
||||
"monitor.search": "Поиск узлов, групп, тегов и заметок",
|
||||
"monitor.interval": "Интервал обновления",
|
||||
"monitor.range": "Период графика",
|
||||
"monitor.seconds": "Каждые {count} с",
|
||||
@@ -33,6 +33,13 @@ export const ruMonitorMessages = {
|
||||
"monitor.sort.name": "Имя",
|
||||
"monitor.selectedCount": "Выбрано серверов: {count}",
|
||||
"monitor.noMatches": "Нет подходящих серверов",
|
||||
"monitor.latency": "Задержка",
|
||||
"monitor.responseLatency": "Задержка ответа (включая обработку на сервере)",
|
||||
"monitor.tags": "Теги",
|
||||
"monitor.notes": "Заметки",
|
||||
"monitor.visibleColumns": "Видимые столбцы",
|
||||
"monitor.terminal": "Терминал",
|
||||
"monitor.resizeColumn": "Изменить ширину столбца «{column}»",
|
||||
"monitor.host": "Сервер",
|
||||
"monitor.hosts": "Серверы",
|
||||
"monitor.status": "Состояние",
|
||||
@@ -52,8 +59,6 @@ export const ruMonitorMessages = {
|
||||
"monitor.memoryTrend": "График памяти",
|
||||
"monitor.downloadTrend": "График приёма",
|
||||
"monitor.uploadTrend": "График передачи",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
|
||||
"monitor.name": "Название панели",
|
||||
"monitor.description": "Описание (необязательно)",
|
||||
"monitor.chooseHosts": "Выбрать серверы",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const ruScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.sidePanel.viewList': 'Список',
|
||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
|
||||
@@ -742,6 +742,7 @@ export const ruTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Поиск сниппетов...',
|
||||
'snippets.action.new': 'Создать',
|
||||
'snippets.action.newSnippet': 'Новый сниппет',
|
||||
'snippets.action.newPackage': 'Новый пакет',
|
||||
'snippets.action.import': 'Импорт',
|
||||
@@ -949,4 +950,69 @@ export const ruTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
|
||||
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
|
||||
|
||||
'terminal.browser.newTab': 'Новая вкладка',
|
||||
'terminal.browser.address': 'Веб-адрес',
|
||||
'terminal.browser.back': 'Назад',
|
||||
'terminal.browser.forward': 'Вперёд',
|
||||
'terminal.browser.reload': 'Обновить',
|
||||
'terminal.browser.stop': 'Остановить',
|
||||
'terminal.browser.bookmarks': 'Приложения и закладки',
|
||||
'terminal.browser.downloads': 'Загрузки',
|
||||
'terminal.browser.empty': 'Добавьте приложение или введите адрес.',
|
||||
'terminal.browser.connecting': 'Подключение…',
|
||||
'terminal.browser.disconnected': 'Соединение недоступно',
|
||||
'terminal.browser.retry': 'Подключиться снова',
|
||||
'terminal.browser.addBookmark': 'Добавить приложение',
|
||||
'terminal.browser.editBookmark': 'Изменить приложение',
|
||||
'terminal.browser.bookmarkTitle': 'Название',
|
||||
'terminal.browser.icon': 'Значок',
|
||||
'terminal.browser.website': 'Значок сайта',
|
||||
'terminal.browser.text': 'Текст',
|
||||
'terminal.browser.image': 'Изображение',
|
||||
'terminal.browser.chooseImage': 'Выбрать изображение',
|
||||
'terminal.browser.color': 'Цвет',
|
||||
'terminal.browser.scope': 'Показывать на',
|
||||
'terminal.browser.hostScope': 'Этот хост',
|
||||
'terminal.browser.allScope': 'Все хосты',
|
||||
'terminal.browser.changeIcon': 'Изменить значок',
|
||||
'terminal.browser.resetIcon': 'Использовать значок сайта',
|
||||
'terminal.browser.downloadEmpty': 'Загрузок пока нет',
|
||||
'terminal.browser.openFolder': 'Открыть папку загрузок',
|
||||
'terminal.browser.clearDownloads': 'Очистить завершённые загрузки',
|
||||
'terminal.browser.cancelDownload': 'Отменить загрузку',
|
||||
'terminal.browser.reveal': 'Показать в папке',
|
||||
'terminal.browser.openFile': 'Открыть файл',
|
||||
'terminal.browser.removeDownload': 'Удалить из списка',
|
||||
'terminal.browser.waiting': 'Подготовка загрузки…',
|
||||
'terminal.browser.progressing': 'Загрузка',
|
||||
'terminal.browser.completed': 'Завершена',
|
||||
'terminal.browser.cancelled': 'Отменена',
|
||||
'terminal.browser.interrupted': 'Прервана',
|
||||
'terminal.browser.missing': 'Файл перемещён или удалён',
|
||||
'terminal.browser.closeTab': 'Закрыть вкладку',
|
||||
'terminal.browser.more': 'Другие действия',
|
||||
'terminal.browser.websiteIconHint': 'Значок загрузится автоматически после посещения сайта.',
|
||||
'terminal.browser.iconText': 'Текст значка',
|
||||
'terminal.browser.iconTextHint': 'Буква, сокращение или эмодзи.',
|
||||
'terminal.browser.selectColor': 'Выбрать {color}',
|
||||
'terminal.browser.customColor': 'Свой цвет',
|
||||
'terminal.browser.iconPreview': 'Предпросмотр значка',
|
||||
'terminal.browser.savedImage': 'Сохранённое изображение',
|
||||
'terminal.browser.processingImage': 'Обработка изображения…',
|
||||
'terminal.browser.replaceImage': 'Заменить',
|
||||
'terminal.browser.removeImage': 'Удалить изображение',
|
||||
'terminal.browser.uploadImage': 'Выбрать изображение',
|
||||
'terminal.browser.dropImage': 'или перетащить его сюда',
|
||||
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF или ICO · до 5 МБ. Размер подбирается автоматически.',
|
||||
'terminal.browser.iconError.file-type': 'Выберите PNG, JPG, WebP, GIF или ICO.',
|
||||
'terminal.browser.iconError.file-size': 'Изображение слишком большое. Выберите файл до 5 МБ.',
|
||||
'terminal.browser.iconError.decode': 'Не удалось прочитать изображение. Выберите другой файл.',
|
||||
'terminal.browser.bookmarkPreviewHint': 'Отображается на начальной странице браузера',
|
||||
'terminal.browser.untitledBookmark': 'Ваше приложение',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': 'Например, Документы команды',
|
||||
'terminal.browser.websiteAddress': 'Адрес сайта',
|
||||
'terminal.browser.hostScopeHint': 'Только в браузере текущего хоста.',
|
||||
'terminal.browser.allScopeHint': 'В браузерах всех хостов.',
|
||||
'terminal.browser.downloadLocation': 'Файлы сохраняются в папку «Загрузки»',
|
||||
'terminal.browser.saveAs': 'Сохранить как…',
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
test('scripts side panel view-toggle copy exists in every locale', () => {
|
||||
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -538,6 +538,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': '搜索标题和近期内容…',
|
||||
'ai.chat.clearSessionSearch': '清空会话搜索',
|
||||
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
|
||||
'ai.chat.searchingSessions': '搜索中…',
|
||||
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.forkFromHere': '从此处分叉',
|
||||
|
||||
@@ -22,7 +22,7 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "图表",
|
||||
"monitor.search": "搜索名称、地址或分组",
|
||||
"monitor.search": "搜索主机、分组、标签或备注",
|
||||
"monitor.interval": "更新频率",
|
||||
"monitor.range": "趋势范围",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
@@ -32,6 +32,13 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.sort.name": "名称",
|
||||
"monitor.selectedCount": "已选 {count} 台主机",
|
||||
"monitor.noMatches": "没有符合条件的主机",
|
||||
"monitor.latency": "延迟",
|
||||
"monitor.responseLatency": "响应延迟(含远端处理)",
|
||||
"monitor.tags": "标签",
|
||||
"monitor.notes": "备注",
|
||||
"monitor.visibleColumns": "显示的列",
|
||||
"monitor.terminal": "终端",
|
||||
"monitor.resizeColumn": "调整「{column}」列宽",
|
||||
"monitor.host": "主机",
|
||||
"monitor.hosts": "主机",
|
||||
"monitor.status": "状态",
|
||||
@@ -51,8 +58,6 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.memoryTrend": "内存趋势",
|
||||
"monitor.downloadTrend": "下载趋势",
|
||||
"monitor.uploadTrend": "上传趋势",
|
||||
"monitor.memoryOnlyHint":
|
||||
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
|
||||
"monitor.name": "面板名称",
|
||||
"monitor.description": "说明(可选)",
|
||||
"monitor.chooseHosts": "选择主机",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.sidePanel.viewList': '列表视图',
|
||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
|
||||
@@ -813,6 +813,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': '搜索脚本...',
|
||||
'snippets.action.new': '新建',
|
||||
'snippets.action.newSnippet': '新建代码片段',
|
||||
'snippets.action.newPackage': '新建脚本包',
|
||||
'snippets.action.import': '导入',
|
||||
@@ -1028,4 +1029,69 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': '保存并关闭',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
|
||||
|
||||
'terminal.browser.newTab': '新标签页',
|
||||
'terminal.browser.address': '输入网址',
|
||||
'terminal.browser.back': '后退',
|
||||
'terminal.browser.forward': '前进',
|
||||
'terminal.browser.reload': '刷新',
|
||||
'terminal.browser.stop': '停止',
|
||||
'terminal.browser.bookmarks': '应用与书签',
|
||||
'terminal.browser.downloads': '下载',
|
||||
'terminal.browser.empty': '添加应用或输入网址即可开始浏览。',
|
||||
'terminal.browser.connecting': '正在连接…',
|
||||
'terminal.browser.disconnected': '连接不可用',
|
||||
'terminal.browser.retry': '重新连接',
|
||||
'terminal.browser.addBookmark': '添加应用',
|
||||
'terminal.browser.editBookmark': '编辑应用',
|
||||
'terminal.browser.bookmarkTitle': '名称',
|
||||
'terminal.browser.icon': '图标',
|
||||
'terminal.browser.website': '网站图标',
|
||||
'terminal.browser.text': '文字',
|
||||
'terminal.browser.image': '图片',
|
||||
'terminal.browser.chooseImage': '选择图片',
|
||||
'terminal.browser.color': '颜色',
|
||||
'terminal.browser.scope': '显示范围',
|
||||
'terminal.browser.hostScope': '此主机',
|
||||
'terminal.browser.allScope': '所有主机',
|
||||
'terminal.browser.changeIcon': '更改图标',
|
||||
'terminal.browser.resetIcon': '使用网站图标',
|
||||
'terminal.browser.downloadEmpty': '暂无下载',
|
||||
'terminal.browser.openFolder': '打开下载文件夹',
|
||||
'terminal.browser.clearDownloads': '清除已结束的下载',
|
||||
'terminal.browser.cancelDownload': '取消下载',
|
||||
'terminal.browser.reveal': '在文件夹中显示',
|
||||
'terminal.browser.openFile': '打开文件',
|
||||
'terminal.browser.removeDownload': '从列表移除',
|
||||
'terminal.browser.waiting': '正在准备下载…',
|
||||
'terminal.browser.progressing': '正在下载',
|
||||
'terminal.browser.completed': '已完成',
|
||||
'terminal.browser.cancelled': '已取消',
|
||||
'terminal.browser.interrupted': '已中断',
|
||||
'terminal.browser.missing': '文件已移动或删除',
|
||||
'terminal.browser.closeTab': '关闭标签页',
|
||||
'terminal.browser.more': '更多操作',
|
||||
'terminal.browser.websiteIconHint': '访问网站后,自动获取并显示网站图标。',
|
||||
'terminal.browser.iconText': '图标文字',
|
||||
'terminal.browser.iconTextHint': '可以使用字母、简称或表情。',
|
||||
'terminal.browser.selectColor': '使用 {color}',
|
||||
'terminal.browser.customColor': '自定义颜色',
|
||||
'terminal.browser.iconPreview': '图标预览',
|
||||
'terminal.browser.savedImage': '已保存的图片',
|
||||
'terminal.browser.processingImage': '正在处理图片…',
|
||||
'terminal.browser.replaceImage': '替换图片',
|
||||
'terminal.browser.removeImage': '移除图片',
|
||||
'terminal.browser.uploadImage': '选择图片',
|
||||
'terminal.browser.dropImage': '也可以将图片拖到这里',
|
||||
'terminal.browser.imageHint': '支持 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自动适配图标大小。',
|
||||
'terminal.browser.iconError.file-type': '请选择 PNG、JPG、WebP、GIF 或 ICO 图片。',
|
||||
'terminal.browser.iconError.file-size': '图片太大,请选择不超过 5 MB 的文件。',
|
||||
'terminal.browser.iconError.decode': '无法读取这张图片,请换一个文件。',
|
||||
'terminal.browser.bookmarkPreviewHint': '显示在浏览器开始页',
|
||||
'terminal.browser.untitledBookmark': '应用预览',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': '例如,团队文档',
|
||||
'terminal.browser.websiteAddress': '网址',
|
||||
'terminal.browser.hostScopeHint': '仅在当前主机的浏览器中显示。',
|
||||
'terminal.browser.allScopeHint': '在每台主机的浏览器中显示。',
|
||||
'terminal.browser.downloadLocation': '文件保存在系统“下载”文件夹',
|
||||
'terminal.browser.saveAs': '另存为…',
|
||||
};
|
||||
@@ -528,6 +528,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.searchSessions': '搜尋標題和近期內容…',
|
||||
'ai.chat.clearSessionSearch': '清空會話搜尋',
|
||||
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
|
||||
'ai.chat.searchingSessions': '搜尋中…',
|
||||
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
'ai.chat.forkFromHere': '從此處分叉',
|
||||
|
||||
@@ -22,7 +22,7 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "圖表",
|
||||
"monitor.search": "搜尋名稱、位址或群組",
|
||||
"monitor.search": "搜尋主機、群組、標籤或備註",
|
||||
"monitor.interval": "更新頻率",
|
||||
"monitor.range": "趨勢範圍",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
@@ -32,6 +32,13 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.sort.name": "名稱",
|
||||
"monitor.selectedCount": "已選 {count} 台主機",
|
||||
"monitor.noMatches": "沒有符合條件的主機",
|
||||
"monitor.latency": "延遲",
|
||||
"monitor.responseLatency": "回應延遲(含遠端處理)",
|
||||
"monitor.tags": "標籤",
|
||||
"monitor.notes": "備註",
|
||||
"monitor.visibleColumns": "顯示的欄位",
|
||||
"monitor.terminal": "終端",
|
||||
"monitor.resizeColumn": "調整「{column}」欄寬",
|
||||
"monitor.host": "主機",
|
||||
"monitor.hosts": "主機",
|
||||
"monitor.status": "狀態",
|
||||
@@ -51,8 +58,6 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.memoryTrend": "記憶體趨勢",
|
||||
"monitor.downloadTrend": "下載趨勢",
|
||||
"monitor.uploadTrend": "上傳趨勢",
|
||||
"monitor.memoryOnlyHint":
|
||||
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
|
||||
"monitor.name": "面板名稱",
|
||||
"monitor.description": "說明(選填)",
|
||||
"monitor.chooseHosts": "選擇主機",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.sidePanel.viewList': '列表檢視',
|
||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
|
||||
@@ -813,6 +813,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': '搜尋程式碼片段...',
|
||||
'snippets.action.new': '新增',
|
||||
'snippets.action.newSnippet': '新增程式碼片段',
|
||||
'snippets.action.newPackage': '新增程式碼包',
|
||||
'snippets.action.import': '匯入',
|
||||
@@ -1028,4 +1029,69 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': '儲存並關閉',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未儲存的編輯器,請先處理後再離開',
|
||||
|
||||
'terminal.browser.newTab': '新增分頁',
|
||||
'terminal.browser.address': '輸入網址',
|
||||
'terminal.browser.back': '上一頁',
|
||||
'terminal.browser.forward': '下一頁',
|
||||
'terminal.browser.reload': '重新整理',
|
||||
'terminal.browser.stop': '停止',
|
||||
'terminal.browser.bookmarks': '應用程式與書籤',
|
||||
'terminal.browser.downloads': '下載',
|
||||
'terminal.browser.empty': '新增應用程式或輸入網址即可開始瀏覽。',
|
||||
'terminal.browser.connecting': '正在連線…',
|
||||
'terminal.browser.disconnected': '連線無法使用',
|
||||
'terminal.browser.retry': '重新連線',
|
||||
'terminal.browser.addBookmark': '新增應用程式',
|
||||
'terminal.browser.editBookmark': '編輯應用程式',
|
||||
'terminal.browser.bookmarkTitle': '名稱',
|
||||
'terminal.browser.icon': '圖示',
|
||||
'terminal.browser.website': '網站圖示',
|
||||
'terminal.browser.text': '文字',
|
||||
'terminal.browser.image': '圖片',
|
||||
'terminal.browser.chooseImage': '選擇圖片',
|
||||
'terminal.browser.color': '顏色',
|
||||
'terminal.browser.scope': '顯示範圍',
|
||||
'terminal.browser.hostScope': '此主機',
|
||||
'terminal.browser.allScope': '所有主機',
|
||||
'terminal.browser.changeIcon': '變更圖示',
|
||||
'terminal.browser.resetIcon': '使用網站圖示',
|
||||
'terminal.browser.downloadEmpty': '尚無下載',
|
||||
'terminal.browser.openFolder': '開啟下載資料夾',
|
||||
'terminal.browser.clearDownloads': '清除已結束的下載',
|
||||
'terminal.browser.cancelDownload': '取消下載',
|
||||
'terminal.browser.reveal': '在資料夾中顯示',
|
||||
'terminal.browser.openFile': '開啟檔案',
|
||||
'terminal.browser.removeDownload': '從清單移除',
|
||||
'terminal.browser.waiting': '正在準備下載…',
|
||||
'terminal.browser.progressing': '正在下載',
|
||||
'terminal.browser.completed': '已完成',
|
||||
'terminal.browser.cancelled': '已取消',
|
||||
'terminal.browser.interrupted': '已中斷',
|
||||
'terminal.browser.missing': '檔案已移動或刪除',
|
||||
'terminal.browser.closeTab': '關閉分頁',
|
||||
'terminal.browser.more': '更多操作',
|
||||
'terminal.browser.websiteIconHint': '造訪網站後,自動取得並顯示網站圖示。',
|
||||
'terminal.browser.iconText': '圖示文字',
|
||||
'terminal.browser.iconTextHint': '可以使用字母、簡稱或表情符號。',
|
||||
'terminal.browser.selectColor': '使用 {color}',
|
||||
'terminal.browser.customColor': '自訂顏色',
|
||||
'terminal.browser.iconPreview': '圖示預覽',
|
||||
'terminal.browser.savedImage': '已儲存的圖片',
|
||||
'terminal.browser.processingImage': '正在處理圖片…',
|
||||
'terminal.browser.replaceImage': '替換圖片',
|
||||
'terminal.browser.removeImage': '移除圖片',
|
||||
'terminal.browser.uploadImage': '選擇圖片',
|
||||
'terminal.browser.dropImage': '也可以將圖片拖到這裡',
|
||||
'terminal.browser.imageHint': '支援 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自動調整圖示大小。',
|
||||
'terminal.browser.iconError.file-type': '請選擇 PNG、JPG、WebP、GIF 或 ICO 圖片。',
|
||||
'terminal.browser.iconError.file-size': '圖片太大,請選擇不超過 5 MB 的檔案。',
|
||||
'terminal.browser.iconError.decode': '無法讀取這張圖片,請更換檔案。',
|
||||
'terminal.browser.bookmarkPreviewHint': '顯示在瀏覽器起始頁',
|
||||
'terminal.browser.untitledBookmark': '應用程式預覽',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': '例如,團隊文件',
|
||||
'terminal.browser.websiteAddress': '網址',
|
||||
'terminal.browser.hostScopeHint': '僅在目前主機的瀏覽器中顯示。',
|
||||
'terminal.browser.allScopeHint': '在每台主機的瀏覽器中顯示。',
|
||||
'terminal.browser.downloadLocation': '檔案儲存在系統「下載項目」資料夾',
|
||||
'terminal.browser.saveAs': '另存新檔…',
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
publishMonitorSnapshot,
|
||||
getMonitorHistoryCursor,
|
||||
} from "./monitorRuntimeStore";
|
||||
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
|
||||
|
||||
test("renderer cursor acknowledges GPU publication separately from CPU sample and history", () => {
|
||||
const snapshot: MonitorSnapshot = {
|
||||
historyMode: "replace",
|
||||
runtimeId: "gpu-cursor-test",
|
||||
epoch: 1,
|
||||
takenAt: 1000,
|
||||
running: ["panel"],
|
||||
count: 1,
|
||||
hostCount: 1,
|
||||
activeSessions: [],
|
||||
startedAt: { panel: 0 },
|
||||
hosts: {
|
||||
host: {
|
||||
historyId: "history",
|
||||
status: "live",
|
||||
at: 1000,
|
||||
statsAt: 1001,
|
||||
points: [
|
||||
{ at: 1000, cpu: 10, memory: 20, disk: null, rx: null, tx: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
publishMonitorSnapshot(snapshot);
|
||||
assert.deepEqual(getMonitorHistoryCursor().after.host, {
|
||||
at: 1000,
|
||||
statsAt: 1001,
|
||||
historyId: "history",
|
||||
});
|
||||
publishMonitorSnapshot({
|
||||
...snapshot,
|
||||
historyMode: "append",
|
||||
takenAt: 2000,
|
||||
hosts: { host: { ...snapshot.hosts.host, statsAt: 2001, points: [] } },
|
||||
});
|
||||
assert.deepEqual(getMonitorHistoryCursor().after.host, {
|
||||
at: 1000,
|
||||
statsAt: 2001,
|
||||
historyId: "history",
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||
return {
|
||||
runtimeId: snapshot.runtimeId,
|
||||
epoch: snapshot.epoch,
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.statsAt ?? host.at, historyId: host.historyId }])),
|
||||
};
|
||||
}
|
||||
export function publishMonitorUnavailable() {
|
||||
|
||||
@@ -788,3 +788,44 @@ test("history alone bounds title pinyin while retaining literal title and body m
|
||||
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
|
||||
});
|
||||
|
||||
test("cooperative search yields and preserves the synchronous matching and order", () => {
|
||||
const search = createSessionHistorySearch();
|
||||
const sessions = Array.from({ length: 90 }, (_, index) => createSession(
|
||||
String(index), `Session ${index}`, [{ content: index % 7 === 0 ? "needle 中文" : "ordinary body" }],
|
||||
));
|
||||
const expected = search.filterSessionHistory(sessions, "needle");
|
||||
const scan = search.createScan<typeof sessions[number]>("needle");
|
||||
assert.equal(scan.step(sessions), null, "a large cached search still yields between slices");
|
||||
let result: typeof sessions | null = null;
|
||||
for (let step = 0; step < 20 && result === null; step += 1) result = scan.step(sessions);
|
||||
assert.deepEqual(result, expected);
|
||||
scan.dispose();
|
||||
assert.equal(scan.step(sessions), null);
|
||||
});
|
||||
|
||||
test("cooperative search incorporates live replacements and removals without restarting stable work", () => {
|
||||
let reads = 0;
|
||||
const stable = Array.from({ length: 90 }, (_, index) => {
|
||||
const session = createSession(String(index), `Session ${index}`, []);
|
||||
session.messages = [{ get content() { reads += 1; return index % 7 === 0 ? "needle" : "ordinary"; } }];
|
||||
return session;
|
||||
});
|
||||
const search = createSessionHistorySearch();
|
||||
const scan = search.createScan<typeof stable[number]>("needle");
|
||||
let result: typeof stable | null = null;
|
||||
let sessions = stable;
|
||||
let steps = 0;
|
||||
while (result === null && steps < 30) {
|
||||
// The newest session changes on every slice, like an active stream.
|
||||
const streaming = createSession("streaming", `Streaming ${steps}`, [{ content: "needle" }]);
|
||||
sessions = [streaming, ...stable.slice(0, -1).reverse()];
|
||||
result = scan.step(sessions);
|
||||
steps += 1;
|
||||
}
|
||||
assert.ok(result, "a live stream cannot starve the other sessions");
|
||||
assert.ok(steps < 10, `stable work restarted (${steps} slices)`);
|
||||
assert.deepEqual(result, sessions.filter((session) => session.id === "streaming" || Number(session.id) % 7 === 0));
|
||||
assert.equal(reads, 89, "each retained stable session is indexed only once");
|
||||
scan.dispose();
|
||||
});
|
||||
@@ -349,6 +349,56 @@ function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
|
||||
}
|
||||
}
|
||||
|
||||
/** One query retains only match decisions while its indexes stay memory-bounded. */
|
||||
export type SessionHistorySearchScan<T extends SessionHistorySearchTarget> = {
|
||||
/** Null means more work remains; each call yields after a short bounded slice. */
|
||||
step: (sessions: readonly T[]) => T[] | null;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
function createSessionHistorySearchScan<T extends SessionHistorySearchTarget>(
|
||||
store: SessionHistorySearchStore,
|
||||
query: string,
|
||||
options: SessionHistorySearchOptions,
|
||||
): SessionHistorySearchScan<T> {
|
||||
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(query.trim()));
|
||||
const matches = new Map<T, boolean>();
|
||||
let disposed = false;
|
||||
store.scanTouched.clear();
|
||||
return {
|
||||
step(sessions) {
|
||||
if (disposed) return null;
|
||||
const live = new Set<SessionHistorySearchTarget>(sessions);
|
||||
for (const session of matches.keys()) {
|
||||
if (!live.has(session)) matches.delete(session);
|
||||
}
|
||||
for (const session of store.scanTouched) {
|
||||
if (!live.has(session)) store.scanTouched.delete(session);
|
||||
}
|
||||
pruneSessionSearchIndexCache(store, sessions);
|
||||
const startedAt = performance.now();
|
||||
let processed = 0;
|
||||
for (const session of sessions) {
|
||||
if (matches.has(session)) continue;
|
||||
const index = getSessionSearchIndex(store, session, options.untitledLabel ?? '');
|
||||
matches.set(session, matchesSessionSearchIndex(preparedQuery, index));
|
||||
processed += 1;
|
||||
// A session already has a bounded searchable corpus. Also cap the
|
||||
// number per slice so a fast clock/cache cannot make a huge task.
|
||||
if (processed >= 16 || performance.now() - startedAt >= 4) return null;
|
||||
}
|
||||
pruneSessionSearchIndexCache(store, sessions);
|
||||
store.scanTouched.clear();
|
||||
return sessions.filter((session) => matches.get(session));
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
matches.clear();
|
||||
store.scanTouched.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One stateful search instance per consumer (one mounted session history
|
||||
* drawer). The cache lives in the returned instance's store instead of module
|
||||
@@ -362,6 +412,10 @@ export type SessionHistorySearch = {
|
||||
query: string,
|
||||
options?: SessionHistorySearchOptions,
|
||||
) => T[];
|
||||
createScan: <T extends SessionHistorySearchTarget>(
|
||||
query: string,
|
||||
options?: SessionHistorySearchOptions,
|
||||
) => SessionHistorySearchScan<T>;
|
||||
clearSessionHistorySearchCache: () => void;
|
||||
};
|
||||
|
||||
@@ -375,6 +429,9 @@ export function createSessionHistorySearch(): SessionHistorySearch {
|
||||
filterSessionHistory(sessions, query, options = {}) {
|
||||
return filterSessionHistoryStore(store, sessions, query, options);
|
||||
},
|
||||
createScan(query, options = {}) {
|
||||
return createSessionHistorySearchScan(store, query, options);
|
||||
},
|
||||
clearSessionHistorySearchCache() {
|
||||
clearSessionHistorySearchStore(store);
|
||||
},
|
||||
|
||||
@@ -4007,3 +4007,111 @@ test("partial cancel failure resets recovered folder children so the re-walk can
|
||||
assert.equal(afterFullCancel.find((row) => row.id === id)?.status, "cancelled");
|
||||
}
|
||||
});
|
||||
|
||||
test("lifecycle hierarchy snapshot stays stable on bytes and changes on actual state transitions", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("panel", [makeTask("hierarchy-live")]);
|
||||
const hierarchy = store.getLifecycleSnapshot();
|
||||
store.patchTask("hierarchy-live", { transferredBytes: 5, speed: 3 });
|
||||
assert.equal(store.getLifecycleSnapshot(), hierarchy);
|
||||
assert.equal(store.getTask("hierarchy-live")?.transferredBytes, 5);
|
||||
store.getSnapshot(); // Other progress consumers must not invalidate the hierarchy.
|
||||
assert.equal(store.getLifecycleSnapshot(), hierarchy);
|
||||
store.patchTask("hierarchy-live", { status: "failed", error: "fixture failure" });
|
||||
assert.notEqual(store.getLifecycleSnapshot(), hierarchy);
|
||||
assert.equal(store.getLifecycleSnapshot().tasks[0].status, "failed");
|
||||
});
|
||||
|
||||
test("progress that admits or changes a task lifecycle also refreshes the hierarchy", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const initial = store.getLifecycleSnapshot();
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "late-start", transferred: 1 });
|
||||
const admitted = store.getLifecycleSnapshot();
|
||||
assert.notEqual(admitted, initial);
|
||||
assert.equal(admitted.tasks[0].id, "late-start");
|
||||
store.publishOwner("panel", [makeTask("queued-progress", "queued")]);
|
||||
const queued = store.getLifecycleSnapshot();
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "queued-progress", transferred: 3 });
|
||||
assert.notEqual(store.getLifecycleSnapshot(), queued);
|
||||
assert.equal(store.getLifecycleSnapshot().tasks.find((task) => task.id === "queued-progress")?.status, "transferring");
|
||||
const live = store.getLifecycleSnapshot();
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "queued-progress", transferred: 4 });
|
||||
assert.equal(store.getLifecycleSnapshot(), live);
|
||||
});
|
||||
|
||||
test("admission updates hierarchy state without forcing full owner lifecycle work", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = makeTask("admitted-hierarchy", "queued");
|
||||
store.publishOwner("panel", [task]);
|
||||
const queued = store.getLifecycleSnapshot();
|
||||
let lifecycleNotifications = 0;
|
||||
store.subscribe(() => { lifecycleNotifications += 1; });
|
||||
assert.equal(store.admitTaskRun(task), "ready");
|
||||
assert.notEqual(store.getLifecycleSnapshot(), queued);
|
||||
assert.equal(store.getLifecycleSnapshot().tasks[0].status, "transferring");
|
||||
assert.equal(lifecycleNotifications, 0, "dispatch admission keeps its existing lightweight fanout");
|
||||
});
|
||||
|
||||
|
||||
test("same-byte and ignored terminal events publish repaired background hierarchy immediately", () => {
|
||||
for (const { status, type } of [
|
||||
{ status: "transferring", type: "progress" },
|
||||
{ status: "completed", type: "progress" },
|
||||
{ status: "completed", type: "completed" },
|
||||
{ status: "failed", type: "failed" },
|
||||
] as const) {
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("panel", [{ ...makeTask("folder"), isDirectory: true }]);
|
||||
store.publishOwner("background-agent", [{ ...makeTask("orphan", status), background: true }]);
|
||||
const beforeRepair = store.getLifecycleSnapshot();
|
||||
let lifecycleNotifications = 0;
|
||||
store.subscribe(() => { lifecycleNotifications += 1; });
|
||||
store.ingestBackgroundEvent({ type, transferId: "orphan", parentTaskId: "folder", transferred: 2 });
|
||||
const repaired = store.getLifecycleSnapshot();
|
||||
assert.notEqual(repaired, beforeRepair, status);
|
||||
assert.equal(repaired.tasks.find((task) => task.id === "orphan")?.parentTaskId, "folder", status);
|
||||
assert.equal(repaired.tasks.find((task) => task.id === "orphan")?.background, false, status);
|
||||
assert.equal(lifecycleNotifications, 0, "hierarchy repair keeps lightweight owner fanout");
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 3 });
|
||||
assert.equal(store.getLifecycleSnapshot(), repaired, "subsequent byte ticks preserve the repaired hierarchy without rebuilding it");
|
||||
assert.equal(store.getTask("orphan")?.parentTaskId, "folder");
|
||||
}
|
||||
});
|
||||
|
||||
test("repaired terminal hierarchy survives restart without another lifecycle event", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: Date.now() });
|
||||
for (const status of ["completed", "failed", "cancelled"] as const) {
|
||||
let persisted = "";
|
||||
let writes = 0;
|
||||
const store = createSftpTransferCenterStore({ read: () => null, write: (value) => { persisted = value; writes += 1; } });
|
||||
store.publishOwner("panel", [{ ...makeTask("folder"), isDirectory: true }]);
|
||||
store.publishOwner("background-agent", [{ ...makeTask("orphan", status), background: true }]);
|
||||
t.mock.timers.tick(300);
|
||||
assert.equal(JSON.parse(persisted).tasks.find((task: TransferTask) => task.id === "orphan")?.parentTaskId, undefined);
|
||||
let lifecycleNotifications = 0;
|
||||
store.subscribe(() => { lifecycleNotifications += 1; });
|
||||
const beforeRepairWrites = writes;
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 2 });
|
||||
t.mock.timers.tick(300);
|
||||
assert.equal(writes, beforeRepairWrites + 1, "structural repair is saved even when the terminal event is ignored");
|
||||
const savedChild = JSON.parse(persisted).tasks.find((task: TransferTask) => task.id === "orphan");
|
||||
assert.equal(savedChild?.parentTaskId, "folder", status);
|
||||
assert.equal(savedChild?.background, false, status);
|
||||
assert.equal(savedChild?.ownerId, "panel", status);
|
||||
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
const child = restored.getTask("orphan");
|
||||
if (status === "completed") {
|
||||
assert.equal(child, undefined, "existing restore compaction retains completed children in their folder checkpoint");
|
||||
assert.equal(restored.getTask("folder")?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
} else {
|
||||
assert.equal(child?.parentTaskId, "folder", status);
|
||||
assert.equal(child?.background, false, status);
|
||||
assert.equal(child?.ownerId, "panel", status);
|
||||
}
|
||||
assert.equal(lifecycleNotifications, 0, "repair retains lightweight owner notifications");
|
||||
const settledWrites = writes;
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 3 });
|
||||
t.mock.timers.tick(300);
|
||||
assert.equal(writes, settledWrites, "unchanged late progress does not schedule recurring writes");
|
||||
}
|
||||
});
|
||||
@@ -109,6 +109,8 @@ export interface SftpTransferCenterStore {
|
||||
*/
|
||||
subscribeProgress(listener: Listener): () => void;
|
||||
getSnapshot(): SftpTransferCenterSnapshot;
|
||||
/** Stable task hierarchy between lifecycle changes; live rows read getTask. */
|
||||
getLifecycleSnapshot(): SftpTransferCenterSnapshot;
|
||||
/** Stable identity when badge counts are unchanged (avoids TopTabs thrash). */
|
||||
getBadgeSnapshot(): { count: number; hasAttention: boolean };
|
||||
/** Single task row — same object identity until that row is patched. */
|
||||
@@ -213,6 +215,21 @@ function buildBadgeSnapshot(tasks: readonly TransferTask[]): { count: number; ha
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields used by grouping, ordering and batch-action eligibility. */
|
||||
function hasTransferListMetadataChange(before: TransferTask | undefined, after: TransferTask | undefined): boolean {
|
||||
if (!before || !after) return before !== after;
|
||||
return before.status !== after.status
|
||||
|| before.parentTaskId !== after.parentTaskId
|
||||
|| before.background !== after.background
|
||||
|| before.startTime !== after.startTime
|
||||
|| before.priority !== after.priority
|
||||
|| before.ownerId !== after.ownerId
|
||||
|| before.resumable !== after.resumable
|
||||
|| before.reconnectRequired !== after.reconnectRequired
|
||||
|| Boolean(before.conflict) !== Boolean(after.conflict)
|
||||
|| (before.status === "failed" && ((before.checkpointBytes ?? 0) > 0) !== ((after.checkpointBytes ?? 0) > 0));
|
||||
}
|
||||
|
||||
const TERMINAL_OWNER_STATUSES = new Set<TransferTask["status"]>([
|
||||
"completed",
|
||||
"cancelled",
|
||||
@@ -420,6 +437,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
event: BackgroundTransferEvent;
|
||||
}>>();
|
||||
let snapshot = tasks.length > 0 ? buildSnapshot(tasks) : EMPTY_SNAPSHOT;
|
||||
let lifecycleSnapshot = snapshot;
|
||||
let badgeSnapshot = buildBadgeSnapshot(tasks);
|
||||
let snapshotDirty = false;
|
||||
const listeners = new Set<Listener>();
|
||||
@@ -695,6 +713,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
}
|
||||
snapshotDirty = false;
|
||||
snapshot = buildSnapshot(tasks);
|
||||
lifecycleSnapshot = snapshot;
|
||||
refreshBadgeSnapshot();
|
||||
persist(shouldPersistImmediately);
|
||||
notifyDedicatedResumeWaiters();
|
||||
@@ -707,19 +726,24 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
};
|
||||
|
||||
/** Progress-only: mutate tasks, defer snapshot rebuild, wake progress UI only. */
|
||||
const emitProgress = (persistImmediately = false) => {
|
||||
const emitProgress = (persistImmediately = false, lifecycleChanged = false) => {
|
||||
if (deferRestoreReplayEmits) {
|
||||
snapshotDirty = true;
|
||||
persistenceDirty = persistenceDirty || persistImmediately;
|
||||
return;
|
||||
}
|
||||
snapshotDirty = true;
|
||||
// Admission/late progress may change a row's lifecycle without the costly
|
||||
// prune/persist path. Keep the list hierarchy current, coalescing its UI
|
||||
// notification with progress instead of waking owner lifecycle listeners.
|
||||
if (lifecycleChanged) lifecycleSnapshot = buildSnapshot(tasks);
|
||||
// Never JSON.stringify/localStorage on the pure-progress path. Sync storage
|
||||
// on large directory histories freezes CSS spinners every few seconds.
|
||||
// Pause / complete / fingerprint / lifecycle still force persist below.
|
||||
if (persistImmediately) {
|
||||
snapshotDirty = false;
|
||||
snapshot = buildSnapshot(tasks);
|
||||
lifecycleSnapshot = snapshot;
|
||||
refreshBadgeSnapshot();
|
||||
persist(true);
|
||||
notifyDedicatedResumeWaiters();
|
||||
@@ -1401,6 +1425,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
return () => progressListeners.delete(listener);
|
||||
},
|
||||
getSnapshot: () => ensureSnapshot(),
|
||||
getLifecycleSnapshot: () => lifecycleSnapshot,
|
||||
getBadgeSnapshot: () => badgeSnapshot,
|
||||
admitTaskRun(incoming, pausedAtResume, completedAtRestart) {
|
||||
const rootId = incoming.parentTaskId ?? incoming.id;
|
||||
@@ -1454,7 +1479,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
};
|
||||
tasks = next;
|
||||
captureObservedTask(next[index]);
|
||||
emitProgress(false);
|
||||
emitProgress(false, hasTransferListMetadataChange(existing, next[index]));
|
||||
return "ready";
|
||||
},
|
||||
observeTaskSettlement(expected, ignoredCompletion, onIdentityConflict) {
|
||||
@@ -1773,7 +1798,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (fingerprintChanged) emit(true);
|
||||
else if (directoryCheckpointChanged) emit(true);
|
||||
else if (lifecycleChanged) emit();
|
||||
else emitProgress(false);
|
||||
else emitProgress(false, hasTransferListMetadataChange(task, nextTask));
|
||||
}
|
||||
},
|
||||
upsertTasks(incoming) {
|
||||
@@ -2173,6 +2198,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
}
|
||||
let existingIndex = tasks.findIndex((task) => task.id === event.transferId);
|
||||
let existing = tasks[existingIndex];
|
||||
const beforeEvent = existing;
|
||||
let parentControlsChanged = false;
|
||||
const eventParent = event.parentTaskId
|
||||
? tasks.find((task) => task.id === event.parentTaskId)
|
||||
: undefined;
|
||||
@@ -2190,7 +2217,16 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
} : task);
|
||||
existing = tasks[existingIndex];
|
||||
captureObservedTask(existing);
|
||||
// Structural recovery must survive restart even if an unchanged or
|
||||
// already-terminal event returns before the normal persistence path.
|
||||
persist();
|
||||
}
|
||||
// Hierarchy repair has already changed the live row. Even an otherwise
|
||||
// ignored event must publish that change; ordinary progress still emits
|
||||
// only once at the end, without a second directory-sized snapshot build.
|
||||
const publishRepairedHierarchy = () => {
|
||||
if (existing !== beforeEvent) emitProgress(false, true);
|
||||
};
|
||||
// A delayed child progress event can arrive after the renderer compacted
|
||||
// that completed child into its parent checkpoint. Never resurrect it as
|
||||
// a live row, especially after the parent itself has finished.
|
||||
@@ -2216,9 +2252,11 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// Allow a late explicit cancel to stick; ignore everything else.
|
||||
if (event.type === "completed" || event.type === "failed") {
|
||||
// Keep existing terminal state (prefer cancelled over late completed).
|
||||
publishRepairedHierarchy();
|
||||
return;
|
||||
}
|
||||
if (event.type === "queued" || event.type === "started" || event.type === "progress" || event.type === "pausing" || event.type === "resumed" || event.type === "paused") {
|
||||
publishRepairedHierarchy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2263,6 +2301,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
&& sameOptional(event.resumable, existing.resumable)
|
||||
&& sameOptional(event.pauseUnavailableReason, existing.pauseUnavailableReason)
|
||||
) {
|
||||
publishRepairedHierarchy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2452,6 +2491,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
};
|
||||
});
|
||||
if (event.resumable === false && existing.parentTaskId) {
|
||||
const parent = tasks.find((task) => task.id === existing.parentTaskId);
|
||||
parentControlsChanged = parent !== undefined && parent.resumable !== false;
|
||||
tasks = tasks.map((task) => task.id === existing.parentTaskId ? {
|
||||
...task,
|
||||
resumable: false,
|
||||
@@ -2508,8 +2549,11 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (existingIndex >= 0) captureObservedTask(tasks[existingIndex]);
|
||||
// Progress-only ticks skip prune + coalesce listener paints; lifecycle
|
||||
// events still take the full emit path so UI never lags on pause/cancel.
|
||||
const currentTask = tasks[existingIndex];
|
||||
const hierarchyOrLifecycleChanged = parentControlsChanged
|
||||
|| hasTransferListMetadataChange(beforeEvent, currentTask);
|
||||
if (event.type === "progress" && !persistImmediately) {
|
||||
emitProgress(false);
|
||||
emitProgress(false, hierarchyOrLifecycleChanged);
|
||||
} else {
|
||||
emit(persistImmediately);
|
||||
}
|
||||
|
||||
@@ -195,3 +195,82 @@ test("normal manual and debounced auto-sync upload panel creation, edits and del
|
||||
Object.assign(manager, originals);
|
||||
}
|
||||
});
|
||||
|
||||
test("pending-sync indication coalesces edit bursts without repeatedly collecting the vault", async () => {
|
||||
const manager = getCloudSyncManager();
|
||||
const snapshot = {
|
||||
...manager.getState(),
|
||||
securityState: "UNLOCKED" as const,
|
||||
syncState: "IDLE" as const,
|
||||
autoSyncEnabled: true,
|
||||
providers: {
|
||||
...manager.getState().providers,
|
||||
webdav: { provider: "webdav" as const, status: "connected" as const },
|
||||
},
|
||||
};
|
||||
const originals = {
|
||||
getState: manager.getState,
|
||||
loadSyncBase: manager.loadSyncBase,
|
||||
inspectProviderRemote: manager.inspectProviderRemote,
|
||||
setPendingLocalSync: manager.setPendingLocalSync,
|
||||
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
|
||||
};
|
||||
manager.getState = () => snapshot;
|
||||
manager.loadSyncBase = async () => null;
|
||||
manager.inspectProviderRemote = async () => ({ remoteFile: null, payload: null, remoteChanged: false });
|
||||
const pending: boolean[] = [];
|
||||
manager.setPendingLocalSync = (value) => { pending.push(value); };
|
||||
manager.setAvailablePluginSyncProviders = () => {};
|
||||
let collects = 0;
|
||||
let collectBarrier: Promise<void> | undefined;
|
||||
Object.defineProperty(window, "netcatty", { configurable: true, value: {
|
||||
collectPluginSyncSidecars: async () => { collects++; await collectBarrier; return { version: 1, entries: [] }; },
|
||||
} });
|
||||
const apply = async () => {};
|
||||
const config = { hosts: [], keys: [], snippets: [], customGroups: ["fixture"], startupReady: true,
|
||||
onApplyPayload: apply, onApplyConvergentPayload: apply };
|
||||
function Probe({ enabled = true }: { enabled?: boolean }) { useAutoSync({ ...config, enabled }); return null; }
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const wait = async (ms: number) => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, ms)); }); };
|
||||
try {
|
||||
store.clear();
|
||||
writeMonitorPanels([{ id: "burst", name: "Before", description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await wait(500);
|
||||
const initial = collects;
|
||||
assert.ok(initial > 0, "startup still collects a real sync baseline");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await act(async () => {
|
||||
writeMonitorPanels([{ id: "burst", name: `Edit ${i}`, description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
});
|
||||
}
|
||||
assert.equal(collects, initial, "editing must not repeatedly collect all sync data");
|
||||
await wait(350);
|
||||
assert.equal(collects, initial + 1, "the final edit updates the pending indicator once");
|
||||
assert.equal(pending.at(-1), true);
|
||||
writeMonitorPanels([]);
|
||||
await wait(10);
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, { enabled: false })); });
|
||||
const disabled = collects;
|
||||
await wait(350);
|
||||
assert.equal(collects, disabled, "disabling sync cancels the pending collection");
|
||||
assert.equal(pending.at(-1), false);
|
||||
await act(async () => { renderer!.update(React.createElement(Probe)); });
|
||||
await wait(350);
|
||||
let releaseCollection!: () => void;
|
||||
collectBarrier = new Promise<void>((resolve) => { releaseCollection = resolve; });
|
||||
const beforeInflight = collects;
|
||||
writeMonitorPanels([{ id: "burst", name: "New edit", description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
||||
await wait(10);
|
||||
await wait(350);
|
||||
assert.equal(collects, beforeInflight + 1, "the pending collection has started");
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, { enabled: false })); });
|
||||
await act(async () => { releaseCollection(); });
|
||||
assert.equal(pending.at(-1), false, "an old collection cannot re-enable the indicator after disabling sync");
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
Reflect.deleteProperty(window, "netcatty");
|
||||
Object.assign(manager, originals);
|
||||
}
|
||||
});
|
||||
@@ -603,7 +603,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
getDataHashRef.current = getDataHash;
|
||||
}, [getDataHash]);
|
||||
|
||||
const refreshPendingLocalSync = useCallback(async () => {
|
||||
const refreshPendingLocalSync = useCallback(async (isCurrent = () => true) => {
|
||||
if (
|
||||
!enabled
|
||||
|| convergentSyncPaused
|
||||
@@ -618,6 +618,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
try {
|
||||
const currentHash = await getDataHashRef.current();
|
||||
if (!isCurrent()) return;
|
||||
const hashDecision = resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash: lastSyncedDataRef.current,
|
||||
@@ -1148,13 +1149,27 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Reflect unsynced local edits in the top-bar cloud indicator.
|
||||
useEffect(() => {
|
||||
void refreshPendingLocalSync();
|
||||
if (!enabled || convergentSyncPaused || !sync.hasAnyConnectedProvider || !sync.isUnlocked) {
|
||||
manager.setPendingLocalSync(false);
|
||||
return;
|
||||
}
|
||||
// Collecting the pending hash builds the same full payload as an upload.
|
||||
// Let rapid edits settle before doing that work, even with auto-sync off.
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
void refreshPendingLocalSync(() => !cancelled);
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
refreshPendingLocalSync,
|
||||
getDataHash,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
sync.isSyncing,
|
||||
convergentSyncPaused,
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelEvent, HostBrowserState } from '../../domain/hostBrowser';
|
||||
import { useHostBrowserControls } from './useHostBrowserControls';
|
||||
|
||||
const state = (label: string): HostBrowserState => ({ hostId: label, label, route: 'connected', error: null, activeTabId: null, tabs: [] });
|
||||
|
||||
test('browser events stay scoped and a late initial read cannot overwrite a newer page', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
let resolveInitial: (value: HostBrowserState) => void = () => assert.fail('initial read missing');
|
||||
let emit: (event: HostBrowserPanelEvent) => void = () => {};
|
||||
let unsubscribed = false;
|
||||
t.mock.method(netcattyBridge, 'require', () => ({
|
||||
getHostBrowserPanelState: () => new Promise<HostBrowserState>((resolve) => { resolveInitial = resolve; }),
|
||||
commandHostBrowserPanel: async () => state('command'),
|
||||
listHostBrowserPanelBookmarks: async () => [],
|
||||
listHostBrowserPanelDownloads: async () => [],
|
||||
onHostBrowserPanelEvent: (callback: typeof emit) => { emit = callback; return () => { unsubscribed = true; }; },
|
||||
}));
|
||||
let controls: ReturnType<typeof useHostBrowserControls>;
|
||||
function Panel() { controls = useHostBrowserControls('browser-a', true); return null; }
|
||||
let renderer: ReturnType<typeof create>;
|
||||
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
|
||||
await act(async () => { renderer = create(React.createElement(Panel)); });
|
||||
await act(async () => emit({ browserId: 'browser-b', type: 'state', state: state('wrong host') }));
|
||||
assert.equal(controls.state, null);
|
||||
await act(async () => emit({ browserId: 'browser-a', type: 'state', state: state('new page') }));
|
||||
await act(async () => resolveInitial(state('old page')));
|
||||
assert.equal(controls.state?.label, 'new page');
|
||||
await act(async () => renderer.unmount());
|
||||
assert.equal(unsubscribed, true);
|
||||
});
|
||||
|
||||
test('switching browser contexts invalidates an outstanding navigation response', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
let finish: (value: HostBrowserState) => void = () => assert.fail('command missing');
|
||||
const commands: string[] = [];
|
||||
t.mock.method(netcattyBridge, 'require', () => ({
|
||||
getHostBrowserPanelState: async (id: string) => state(id),
|
||||
commandHostBrowserPanel: (id: string) => { commands.push(id); return new Promise<HostBrowserState>((resolve) => { finish = resolve; }); },
|
||||
listHostBrowserPanelBookmarks: async () => [], listHostBrowserPanelDownloads: async () => [],
|
||||
onHostBrowserPanelEvent: () => () => {},
|
||||
}));
|
||||
let controls: ReturnType<typeof useHostBrowserControls>;
|
||||
function Panel({ id }: { id: string }) { controls = useHostBrowserControls(id, true); return null; }
|
||||
let renderer: ReturnType<typeof create>;
|
||||
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
|
||||
await act(async () => { renderer = create(React.createElement(Panel, { id: 'browser-a' })); });
|
||||
let pending: Promise<boolean>;
|
||||
await act(async () => { pending = controls.command({ type: 'navigate', url: 'http://localhost/' }); });
|
||||
await act(async () => renderer.update(React.createElement(Panel, { id: 'browser-b' })));
|
||||
await act(async () => finish(state('late browser-a')));
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(controls.state?.label, 'browser-b');
|
||||
assert.deepEqual(commands, ['browser-a']);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { BrowserBookmark } from '../../domain/browserBookmark';
|
||||
import type { HostBrowserBookmark, HostBrowserCommand, HostBrowserDownload, HostBrowserState } from '../../domain/hostBrowser';
|
||||
import { hostBrowserClient, readHostBrowserIcon } from '../../infrastructure/services/hostBrowserService';
|
||||
|
||||
export function useHostBrowserControls(browserId: string, ready: boolean) {
|
||||
const [state, setState] = useState<HostBrowserState | null>(null);
|
||||
const [bookmarks, setBookmarks] = useState<HostBrowserBookmark[]>([]);
|
||||
const [downloads, setDownloads] = useState<HostBrowserDownload[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [focusAddress, setFocusAddress] = useState(0);
|
||||
const [downloadStarted, setDownloadStarted] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const client = useRef<ReturnType<typeof hostBrowserClient> | null>(null);
|
||||
const report = useCallback((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)), []);
|
||||
|
||||
const command = useCallback(async (input: HostBrowserCommand) => {
|
||||
const current = client.current;
|
||||
const revision = generation.current;
|
||||
if (!current) return false;
|
||||
setError(null);
|
||||
try {
|
||||
const next = await current.command(input);
|
||||
if (revision !== generation.current) return false;
|
||||
setState(next);
|
||||
if (input.type === 'new-tab' || (input.type === 'close-tab' && !next.tabs.find((tab) => tab.id === next.activeTabId)?.url)) setFocusAddress((value) => value + 1);
|
||||
return true;
|
||||
} catch (reason) { if (revision === generation.current) report(reason); return false; }
|
||||
}, [report]);
|
||||
|
||||
useEffect(() => {
|
||||
const revision = ++generation.current;
|
||||
setState(null); setBookmarks([]); setDownloads([]); setError(null); setDownloadStarted(0);
|
||||
if (!ready) return;
|
||||
let current: ReturnType<typeof hostBrowserClient>;
|
||||
try { current = hostBrowserClient(browserId); } catch (reason) { report(reason); return; }
|
||||
client.current = current;
|
||||
let stateRevision = 0;
|
||||
let bookmarkRevision = 0;
|
||||
let downloadRevision = 0;
|
||||
const active = () => revision === generation.current;
|
||||
const refreshBookmarks = async () => {
|
||||
const request = ++bookmarkRevision;
|
||||
try { const items = await current.bookmarks(); if (active() && request === bookmarkRevision) setBookmarks(items); }
|
||||
catch (reason) { if (active()) report(reason); }
|
||||
};
|
||||
const off = current.subscribe((event) => {
|
||||
if (!active()) return;
|
||||
if (event.type === 'state') { stateRevision++; setState(event.state); }
|
||||
if (event.type === 'bookmarks') void refreshBookmarks();
|
||||
if (event.type === 'downloads') {
|
||||
downloadRevision++; setDownloads(event.downloads);
|
||||
if (event.startedId) setDownloadStarted((value) => value + 1);
|
||||
}
|
||||
if (event.type === 'shortcut') {
|
||||
if (event.shortcut.type === 'focus-address') setFocusAddress((value) => value + 1);
|
||||
else void command(event.shortcut.type === 'new-tab' ? { type: 'new-tab' } : { type: event.shortcut.type, tabId: event.shortcut.tabId });
|
||||
}
|
||||
});
|
||||
const requestState = stateRevision;
|
||||
void current.state().then((next) => { if (active() && stateRevision === requestState) setState(next); }).catch((reason) => { if (active()) report(reason); });
|
||||
void refreshBookmarks();
|
||||
const requestDownloads = downloadRevision;
|
||||
void current.downloads().then((items) => { if (active() && requestDownloads === downloadRevision) setDownloads(items); }).catch((reason) => { if (active()) report(reason); });
|
||||
return () => { generation.current = revision + 1; client.current = null; off(); };
|
||||
}, [browserId, ready, command, report]);
|
||||
|
||||
const mutateBookmark = useCallback(async (input: Partial<BrowserBookmark> | string) => {
|
||||
const current = client.current;
|
||||
const revision = generation.current;
|
||||
if (!current) return false;
|
||||
try {
|
||||
if (typeof input === 'string') await current.removeBookmark(input);
|
||||
else await current.saveBookmark(input);
|
||||
const items = await current.bookmarks();
|
||||
if (revision === generation.current) { setBookmarks(items); setError(null); }
|
||||
return true;
|
||||
} catch (reason) { if (revision === generation.current) report(reason); return false; }
|
||||
}, [report]);
|
||||
|
||||
const downloadCommand = useCallback(async (type: Parameters<ReturnType<typeof hostBrowserClient>['downloadCommand']>[0]['type'], id?: string) => {
|
||||
const current = client.current;
|
||||
const revision = generation.current;
|
||||
if (!current) return;
|
||||
try {
|
||||
await current.downloadCommand({ type, id });
|
||||
const items = await current.downloads();
|
||||
if (revision === generation.current) setDownloads(items);
|
||||
} catch (reason) { if (revision === generation.current) report(reason); }
|
||||
}, [report]);
|
||||
|
||||
const readIcon = useCallback((file: File) => readHostBrowserIcon(file), []);
|
||||
return { state, bookmarks, downloads, error, command, mutateBookmark, downloadCommand, focusAddress, downloadStarted,
|
||||
requestAddressFocus: () => setFocusAddress((value) => value + 1), readIcon, dismissError: () => setError(null) };
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { TerminalSettings } from '../../domain/models';
|
||||
import { useHostBrowserPanel } from './useHostBrowserPanel';
|
||||
|
||||
type Layout = Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0];
|
||||
type Result = Awaited<ReturnType<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>>;
|
||||
const layers = [{ image: 'data:image/png;base64,fixture', bounds: { x: 0, y: 0, width: 500, height: 600 } }];
|
||||
|
||||
async function fixture(t: test.TestContext) {
|
||||
const env = installDomEnvironment();
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let frameId = 0;
|
||||
const originals = new Map(['requestAnimationFrame', 'cancelAnimationFrame', 'getComputedStyle']
|
||||
.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
|
||||
Object.defineProperty(globalThis, 'requestAnimationFrame', { configurable: true,
|
||||
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
|
||||
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
|
||||
value: (id: number) => frames.delete(id) });
|
||||
const resizeObservers = new Map<ResizeObserver, Set<Element>>();
|
||||
const resizeCallbacks = new Map<ResizeObserver, ResizeObserverCallback>();
|
||||
class LayoutResizeObserver implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { resizeCallbacks.set(this, callback); resizeObservers.set(this, new Set()); }
|
||||
observe(target: Element) { resizeObservers.get(this)?.add(target); }
|
||||
unobserve(target: Element) { resizeObservers.get(this)?.delete(target); }
|
||||
disconnect() { resizeObservers.delete(this); resizeCallbacks.delete(this); }
|
||||
}
|
||||
Object.defineProperty(env.window, 'ResizeObserver', { configurable: true, value: LayoutResizeObserver });
|
||||
const fonts = Object.assign(new env.window.EventTarget(), { status: 'loaded' });
|
||||
Object.defineProperty(env.document, 'fonts', { configurable: true, value: fonts });
|
||||
const style = env.document.createElement('div').style;
|
||||
style.colorScheme = 'dark';
|
||||
globalThis.getComputedStyle = () => style;
|
||||
const layouts: Layout[] = [];
|
||||
let respond: (layout: Layout) => Promise<Result> = async (layout) => layout.occluded ? { layers } : true;
|
||||
t.mock.method(netcattyBridge, 'get', () => ({
|
||||
setHostBrowserPanelLayout: async (layout: Layout) => { layouts.push(layout); return respond(layout); },
|
||||
openHostBrowser: async () => {},
|
||||
startPortForward: async () => assert.fail('a local browser needs no SSH transport'),
|
||||
closeHostBrowserPanel: async () => {},
|
||||
}));
|
||||
let panel: ReturnType<typeof useHostBrowserPanel>;
|
||||
function Panel({ visible = true, browserId = 'local:overlay-test' }) {
|
||||
panel = useHostBrowserPanel({ browserId,
|
||||
host: { id: 'local', protocol: 'local', label: 'Local terminal', hostname: '', username: '', tags: [], os: 'macos' },
|
||||
hosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {} as TerminalSettings,
|
||||
isVisible: visible });
|
||||
return React.createElement('div', { ref: panel.elementRef });
|
||||
}
|
||||
const element = env.document.createElement('div');
|
||||
env.document.body.append(element);
|
||||
let bounds = new env.window.DOMRect(200, 100, 500, 600);
|
||||
let reads = 0;
|
||||
element.getBoundingClientRect = () => { reads += 1; return bounds; };
|
||||
let renderer: ReturnType<typeof create>;
|
||||
t.after(async () => {
|
||||
if (renderer) await act(async () => renderer.unmount());
|
||||
for (const [name, previous] of originals) {
|
||||
if (previous) Object.defineProperty(globalThis, name, previous);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
await act(async () => { renderer = create(React.createElement(Panel), { createNodeMock: () => element }); });
|
||||
const overlay = (html: string, x = 220, y = 110) => {
|
||||
env.document.body.insertAdjacentHTML('beforeend', html);
|
||||
const node = env.document.body.lastElementChild!;
|
||||
for (const target of [node, ...node.children]) {
|
||||
target.getBoundingClientRect = () => new env.window.DOMRect(x, y, 150, 40);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const tick = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
const pending = [...frames.values()]; frames.clear();
|
||||
for (const callback of pending) callback(0);
|
||||
});
|
||||
};
|
||||
return { layouts, overlay, tick, panel: () => panel, pendingFrames: () => frames.size,
|
||||
env, element, style, fonts, reads: () => reads, setBounds: (next: DOMRect) => { bounds = next; },
|
||||
observerCount: () => resizeObservers.size,
|
||||
resize: () => { for (const [observer, callback] of resizeCallbacks) callback([], observer); },
|
||||
respond: (callback: typeof respond) => { respond = callback; },
|
||||
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
|
||||
}
|
||||
|
||||
const tooltip = '<div data-radix-popper-content-wrapper><div data-state="delayed-open"><span role="tooltip">Split vertically</span></div></div>';
|
||||
|
||||
test('a split-button tooltip keeps the browser picture, then restores its live view', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
const hint = f.overlay(tooltip);
|
||||
await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true, 'the owning panel remains open');
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.panel().snapshot, layers, 'the native page is replaced by its picture, not a blank pane');
|
||||
const count = f.layouts.length;
|
||||
await f.tick();
|
||||
assert.equal(f.layouts.length, count, 'a stationary tooltip captures only once');
|
||||
hint.remove(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
});
|
||||
|
||||
test('unrelated and closed poppers leave the browser live', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.overlay(tooltip, 10, 10);
|
||||
f.overlay('<div data-radix-popper-content-wrapper><div role="menu" data-state="closed"></div></div>');
|
||||
await f.tick();
|
||||
assert.equal(f.layouts.length, 1);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('overlapping nonmodal menus yield with a picture; modal dialogs yield even beside the pane', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const menu = f.overlay('<div data-radix-popper-content-wrapper><div role="dialog" data-state="open"></div></div>');
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
menu.remove(); await f.tick();
|
||||
f.overlay('<div role="dialog" aria-modal="true"></div>', 0, 0);
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
});
|
||||
|
||||
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let complete: (result: Result) => void = () => assert.fail('capture was not started');
|
||||
f.respond((layout) => layout.occluded ? new Promise((resolve) => { complete = resolve; }) : Promise.resolve(true));
|
||||
const menu = f.overlay(tooltip); await f.tick();
|
||||
menu.remove(); await f.tick();
|
||||
await act(async () => complete({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
});
|
||||
|
||||
test('hiding or locking the pane clears pictures without taking another capture', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.overlay(tooltip); await f.tick();
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
f.overlay('<div data-app-lock-overlay role="dialog"></div>'); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, false);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
await f.hide();
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
});
|
||||
|
||||
|
||||
test('a stationary visible browser leaves no repeating layout work', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (let index = 0; index < 600; index += 1) await f.tick();
|
||||
assert.equal(f.layouts.length, 1);
|
||||
assert.equal(f.pendingFrames(), 0, 'idle browser must stop scheduling document/style/geometry scans');
|
||||
});
|
||||
|
||||
|
||||
test('layout, scroll, font, stylesheet and theme changes coalesce without resuming idle scans', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const reads = f.reads();
|
||||
f.setBounds(new f.env.window.DOMRect(100, 50, 400, 500));
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
f.resize();
|
||||
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
|
||||
f.element.dispatchEvent(new f.env.window.Event('scroll', { bubbles: true }));
|
||||
}
|
||||
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
|
||||
f.env.document.dispatchEvent(new f.env.window.Event('load'));
|
||||
f.style.colorScheme = 'light';
|
||||
f.env.document.documentElement.classList.add('light');
|
||||
assert.equal(f.reads(), reads, 'bursts do not synchronously force repeated layout reads');
|
||||
await f.tick();
|
||||
assert.equal(f.reads(), reads + 1);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 100, y: 50, width: 400, height: 500 });
|
||||
assert.equal(f.layouts.at(-1)?.theme, 'light');
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
f.env.document.body.setAttribute('inert', ''); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, false);
|
||||
f.env.document.body.removeAttribute('inert'); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
});
|
||||
|
||||
test('resize observation follows a moving overlay and is released while hidden', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const popper = f.overlay(tooltip, 10, 10); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
popper.firstElementChild!.getBoundingClientRect = () => new f.env.window.DOMRect(220, 110, 150, 40);
|
||||
f.resize(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
await f.hide();
|
||||
assert.equal(f.observerCount(), 0);
|
||||
const reads = f.reads();
|
||||
f.overlay('<div role="dialog" aria-modal="true"></div>');
|
||||
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
|
||||
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
|
||||
await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
assert.equal(f.reads(), reads);
|
||||
});
|
||||
|
||||
test('only pane/ancestor and overlay motion follows frames, then stops at settlement', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const animation = { pending: false, playState: 'running' };
|
||||
f.element.getAnimations = () => [animation as unknown as Animation];
|
||||
f.element.dispatchEvent(new f.env.window.Event('transitionrun', { bubbles: true }));
|
||||
const reads = f.reads();
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
f.setBounds(new f.env.window.DOMRect(200 + index * 10, 100, 500, 600));
|
||||
await f.tick();
|
||||
}
|
||||
assert.equal(f.reads(), reads + 5);
|
||||
assert.equal(f.layouts.at(-1)?.bounds?.x, 240);
|
||||
animation.playState = 'finished'; await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
|
||||
const spinner = f.env.document.createElement('div'); f.env.document.body.append(spinner); await f.tick();
|
||||
spinner.getAnimations = () => [animation as unknown as Animation];
|
||||
const settledReads = f.reads();
|
||||
spinner.dispatchEvent(new f.env.window.Event('animationstart', { bubbles: true }));
|
||||
await f.tick();
|
||||
assert.equal(f.reads(), settledReads, 'unrelated cursor/spinner animation does not wake the browser');
|
||||
});
|
||||
|
||||
test('removing an animating overlay cannot leave an endless layout loop', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const popper = f.overlay(tooltip);
|
||||
popper.firstElementChild!.getAnimations = () => [{ pending: false, playState: 'running' } as Animation];
|
||||
await f.tick();
|
||||
assert.equal(f.pendingFrames(), 1);
|
||||
popper.remove(); await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('an old capture stays obsolete when an identical overlay is opened again', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const captures: ((result: Result) => void)[] = [];
|
||||
f.respond((layout) => layout.occluded ? new Promise((resolve) => { captures.push(resolve); }) : Promise.resolve(true));
|
||||
const first = f.overlay(tooltip); await f.tick();
|
||||
first.remove(); await f.tick();
|
||||
f.overlay(tooltip); await f.tick();
|
||||
assert.equal(captures.length, 2);
|
||||
await act(async () => captures[0]({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, [], 'the first A capture must not become valid after A -> B -> A');
|
||||
await act(async () => captures[1]({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
});
|
||||
@@ -2,6 +2,16 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
|
||||
|
||||
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
|
||||
const overlay = element.getBoundingClientRect();
|
||||
return overlay.width > 0 && overlay.height > 0
|
||||
&& overlay.x < rect.right && overlay.right > rect.x
|
||||
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
|
||||
}
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
browserId: string;
|
||||
@@ -22,33 +32,126 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const mountedBrowserId = useRef<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [readyId, setReadyId] = useState<string | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<{ browserId: string; layers: HostBrowserPanelSnapshotLayer[] } | null>(null);
|
||||
const { browserId, isVisible } = options;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let frame = 0;
|
||||
let frame: number | null = null;
|
||||
let previous = '';
|
||||
let disposed = false;
|
||||
let layoutRevision = 0;
|
||||
const element = elementRef.current;
|
||||
const doc = element?.ownerDocument;
|
||||
const view = doc?.defaultView;
|
||||
const observed = new Set<Element>();
|
||||
const animations = new Map<Animation, Element>();
|
||||
const schedule = () => {
|
||||
if (disposed || frame !== null) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
for (const [animation, target] of animations) {
|
||||
if (!target.isConnected || (!animation.pending && animation.playState !== 'running')) animations.delete(animation);
|
||||
}
|
||||
update();
|
||||
if (animations.size > 0) schedule();
|
||||
});
|
||||
};
|
||||
const trackAnimations = (target: Element) => {
|
||||
for (const animation of target.getAnimations?.() ?? []) {
|
||||
if (!animation.pending && animation.playState !== 'running') continue;
|
||||
animations.set(animation, target);
|
||||
}
|
||||
};
|
||||
const resizeObserver = isVisible && view?.ResizeObserver ? new view.ResizeObserver(schedule) : null;
|
||||
const update = () => {
|
||||
const element = elementRef.current;
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native page views must leave the screen with their owning pane and
|
||||
// must not cover a renderer dialog or a toolbar's open menu.
|
||||
const covered = !!document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper]');
|
||||
// Native views sit above renderer portals. Only overlapping poppers need
|
||||
// to yield; dialogs are modal even when their content is beside the pane.
|
||||
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
||||
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
||||
const overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
if (!targets.has(target)) { resizeObserver?.unobserve(target); observed.delete(target); }
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (observed.has(target)) continue;
|
||||
observed.add(target);
|
||||
resizeObserver?.observe(target);
|
||||
trackAnimations(target);
|
||||
}
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
|
||||
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
browserId, visible, occluded: visible && covered, bounds, theme, themeTokens,
|
||||
};
|
||||
const next = JSON.stringify(layout);
|
||||
if (next !== previous) {
|
||||
previous = next;
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (isVisible) frame = requestAnimationFrame(update);
|
||||
};
|
||||
if (!isVisible) {
|
||||
setSnapshot(null);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout, theme and portal changes coalesce into a single frame. Keep
|
||||
// following only running animations on the pane's ancestors or overlay
|
||||
// containers; unrelated spinner/cursor animations must not restart polling.
|
||||
const mutationObserver = view?.MutationObserver ? new view.MutationObserver(schedule) : null;
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
};
|
||||
const motionEvents = ['transitionrun', 'transitionend', 'transitioncancel', 'animationstart', 'animationend', 'animationcancel'];
|
||||
for (const event of motionEvents) doc?.addEventListener(event, onMotion, true);
|
||||
doc?.addEventListener('scroll', schedule, true);
|
||||
doc?.addEventListener('load', schedule, true);
|
||||
view?.addEventListener('resize', schedule);
|
||||
doc?.fonts?.addEventListener('loadingdone', schedule);
|
||||
doc?.fonts?.addEventListener('loadingerror', schedule);
|
||||
if (doc?.fonts?.status === 'loading') void doc.fonts.ready.then(schedule);
|
||||
update();
|
||||
if (animations.size > 0) schedule();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
disposed = true;
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
for (const event of motionEvents) doc?.removeEventListener(event, onMotion, true);
|
||||
doc?.removeEventListener('scroll', schedule, true);
|
||||
doc?.removeEventListener('load', schedule, true);
|
||||
view?.removeEventListener('resize', schedule);
|
||||
doc?.fonts?.removeEventListener('loadingdone', schedule);
|
||||
doc?.fonts?.removeEventListener('loadingerror', schedule);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
};
|
||||
}, [browserId, isVisible]);
|
||||
@@ -58,7 +161,9 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
opened.current = true;
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).then(() => {
|
||||
if (mountedBrowserId.current === browserId) setReadyId(browserId);
|
||||
}).catch((reason: unknown) => {
|
||||
opened.current = false;
|
||||
if (cancelled) return;
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
@@ -80,5 +185,6 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
};
|
||||
}, [browserId]);
|
||||
|
||||
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
|
||||
return { elementRef, error, ready: readyId === browserId, snapshot: isVisible && snapshot?.browserId === browserId ? snapshot.layers : [],
|
||||
retry: () => setAttempt((value) => value + 1) };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import {
|
||||
clampMonitorColumnWidth,
|
||||
normalizeMonitorColumnWidths,
|
||||
normalizeMonitorHiddenColumns,
|
||||
type MonitorColumn,
|
||||
} from "../../domain/monitorTableLayout";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
STORAGE_KEY_MONITOR_HIDDEN_COLUMNS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
|
||||
export function useMonitorTableColumns() {
|
||||
const [widths, setWidths] = useState(() =>
|
||||
normalizeMonitorColumnWidths(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_TABLE_COLUMNS),
|
||||
),
|
||||
);
|
||||
const [hidden, setHidden] = useState(() =>
|
||||
normalizeMonitorHiddenColumns(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS),
|
||||
),
|
||||
);
|
||||
const currentHidden = useRef(hidden);
|
||||
const setVisible = (key: MonitorColumn, visible: boolean) => {
|
||||
const previous = currentHidden.current;
|
||||
const next = normalizeMonitorHiddenColumns(
|
||||
visible ? previous.filter((item) => item !== key) : [...previous, key],
|
||||
);
|
||||
currentHidden.current = next;
|
||||
setHidden(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS, next);
|
||||
};
|
||||
const current = useRef(widths);
|
||||
const cleanup = useRef<(() => void) | null>(null);
|
||||
useEffect(() => () => cleanup.current?.(), []);
|
||||
const resize = (key: MonitorColumn, width: number, persist = true) => {
|
||||
current.current = {
|
||||
...current.current,
|
||||
[key]: clampMonitorColumnWidth(key, width),
|
||||
};
|
||||
setWidths(current.current);
|
||||
if (persist)
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
};
|
||||
const startResize = (
|
||||
key: MonitorColumn,
|
||||
event: PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cleanup.current?.();
|
||||
const pointerId = event.pointerId;
|
||||
const handle = event.currentTarget;
|
||||
handle.setPointerCapture(pointerId);
|
||||
const start = event.clientX;
|
||||
const width = current.current[key];
|
||||
const cursor = document.body.style.cursor;
|
||||
const userSelect = document.body.style.userSelect;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
const move = (next: globalThis.PointerEvent) => {
|
||||
if (next.pointerId === pointerId)
|
||||
resize(key, width + next.clientX - start, false);
|
||||
};
|
||||
const finish = (next: globalThis.PointerEvent) => {
|
||||
if (next.pointerId !== pointerId) return;
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
cleanup.current?.();
|
||||
};
|
||||
const blur = () => {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
cleanup.current?.();
|
||||
};
|
||||
cleanup.current = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", finish);
|
||||
window.removeEventListener("pointercancel", finish);
|
||||
window.removeEventListener("blur", blur);
|
||||
if (handle.hasPointerCapture(pointerId))
|
||||
handle.releasePointerCapture(pointerId);
|
||||
document.body.style.cursor = cursor;
|
||||
document.body.style.userSelect = userSelect;
|
||||
cleanup.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", finish);
|
||||
window.addEventListener("pointercancel", finish);
|
||||
window.addEventListener("blur", blur);
|
||||
};
|
||||
return { widths, resize, startResize, hidden, setVisible };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
parseScriptsViewMode,
|
||||
SCRIPTS_VIEW_MODE_STORAGE_KEY,
|
||||
} from "./useScriptsViewMode.ts";
|
||||
|
||||
test("scripts view mode parser only accepts 'stacked'", () => {
|
||||
assert.equal(parseScriptsViewMode("stacked"), "stacked");
|
||||
assert.equal(parseScriptsViewMode("list"), "list");
|
||||
assert.equal(parseScriptsViewMode("grid"), "list");
|
||||
assert.equal(parseScriptsViewMode(null), "list");
|
||||
assert.equal(parseScriptsViewMode(""), "list");
|
||||
});
|
||||
|
||||
test("scripts view mode hook persists the chosen mode", () => {
|
||||
const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /useStoredString/);
|
||||
// The shared hook owns persistence and same-/cross-window subscriptions.
|
||||
assert.match(
|
||||
source,
|
||||
/useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/,
|
||||
);
|
||||
assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys";
|
||||
import { useStoredString } from "./useStoredString";
|
||||
|
||||
/** View modes for the scripts side panel library:
|
||||
* 'list' (one snippet per row, tree of packages) and 'stacked' (compact
|
||||
* wrap-around chips). */
|
||||
export type ScriptsViewMode = "list" | "stacked";
|
||||
|
||||
export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW;
|
||||
|
||||
/** Pure resolver so callers and tests share one validation rule. */
|
||||
export const parseScriptsViewMode = (value: string | null): ScriptsViewMode =>
|
||||
value === "stacked" ? "stacked" : "list";
|
||||
|
||||
const isScriptsViewMode = (value: string | null): value is ScriptsViewMode =>
|
||||
value === "list" || value === "stacked";
|
||||
|
||||
export const useScriptsViewMode = () =>
|
||||
useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode);
|
||||
@@ -1,59 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts";
|
||||
|
||||
test("script view changes follow native storage events from another window", async (t) => {
|
||||
const windowA = new EventTarget();
|
||||
const windowB = new EventTarget();
|
||||
let activeWindow = windowA;
|
||||
const data = new Map<string, string>();
|
||||
const values = {
|
||||
localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } },
|
||||
addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener),
|
||||
removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener),
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
}
|
||||
let a!: ReturnType<typeof useScriptsViewMode>;
|
||||
let b!: ReturnType<typeof useScriptsViewMode>;
|
||||
function FirstWindow() { a = useScriptsViewMode(); return null; }
|
||||
function SecondWindow() { b = useScriptsViewMode(); return null; }
|
||||
let first!: ReactTestRenderer;
|
||||
let second!: ReactTestRenderer;
|
||||
t.after(async () => {
|
||||
activeWindow = windowA; await act(async () => first?.unmount());
|
||||
activeWindow = windowB; await act(async () => second?.unmount());
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); });
|
||||
activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); });
|
||||
assert.equal(a[0], "list"); assert.equal(b[0], "list");
|
||||
// BrowserWindow A writes shared storage. Electron emits the native event
|
||||
// in B, not an adapter CustomEvent from A's independent JS environment.
|
||||
activeWindow = windowA; await act(async () => a[1]("stacked"));
|
||||
assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked");
|
||||
assert.equal(b[0], "list");
|
||||
const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => {
|
||||
const event = new Event("storage");
|
||||
Object.defineProperty(event, "key", { value: key });
|
||||
await act(async () => { target.dispatchEvent(event); });
|
||||
};
|
||||
activeWindow = windowB; await notify(windowB);
|
||||
assert.equal(b[0], "stacked");
|
||||
await act(async () => b[1]("list"));
|
||||
assert.equal(a[0], "stacked");
|
||||
activeWindow = windowA; await notify(windowA);
|
||||
assert.equal(a[0], "list");
|
||||
data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
await notify(windowA, "unrelated");
|
||||
assert.equal(a[0], "list", "unrelated preferences must not trigger an update");
|
||||
});
|
||||
@@ -1,24 +1,93 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SessionHistorySearchTarget } from '../../domain/sessionHistorySearch';
|
||||
import {
|
||||
createSessionHistorySearch,
|
||||
type SessionHistorySearch,
|
||||
type SessionHistorySearchScan,
|
||||
} from './sessionHistorySearch';
|
||||
|
||||
/**
|
||||
* Owns one search instance per mounted session history drawer.
|
||||
*
|
||||
* The search index cache is bound to this instance (not module scope), so
|
||||
* several concurrently mounted drawers — one per terminal tab — each keep
|
||||
* their own indexes: pruning in one drawer never drops another drawer's
|
||||
* entries, and the unmount below releases only this drawer's store instead of
|
||||
* discarding the shared cache for every drawer. Cache keys are the session
|
||||
* objects themselves, so clearing on unmount drops all strong session
|
||||
* references together with the retained haystack characters.
|
||||
*/
|
||||
export function useSessionHistorySearch(): SessionHistorySearch {
|
||||
/** Search outside rendering, yielding between bounded slices for input/paint. */
|
||||
export function useSessionHistorySearch<T extends SessionHistorySearchTarget>(
|
||||
sessions: readonly T[],
|
||||
query: string,
|
||||
untitledLabel: string,
|
||||
): { filteredSessions: readonly T[]; isSearching: boolean } {
|
||||
const search = useMemo(() => createSessionHistorySearch(), []);
|
||||
const latest = useRef({ sessions, query, untitledLabel });
|
||||
const work = useRef<{
|
||||
query: string;
|
||||
untitledLabel: string;
|
||||
scan: SessionHistorySearchScan<T>;
|
||||
} | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [result, setResult] = useState<{
|
||||
sessions: readonly T[];
|
||||
query: string;
|
||||
untitledLabel: string;
|
||||
filteredSessions: T[];
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { sessions, query, untitledLabel };
|
||||
}, [sessions, query, untitledLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
const cancel = () => {
|
||||
if (timer.current !== null) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
work.current?.scan.dispose();
|
||||
work.current = null;
|
||||
};
|
||||
if (!query.trim()) {
|
||||
cancel();
|
||||
setResult(null);
|
||||
// Release deleted/replaced sessions without indexing any message bodies.
|
||||
search.filterSessionHistory(sessions, '');
|
||||
return;
|
||||
}
|
||||
if (work.current?.query !== query || work.current.untitledLabel !== untitledLabel) {
|
||||
cancel();
|
||||
work.current = { query, untitledLabel, scan: search.createScan<T>(query, { untitledLabel }) };
|
||||
}
|
||||
if (timer.current !== null) return;
|
||||
const job = work.current;
|
||||
const run = () => {
|
||||
timer.current = null;
|
||||
const current = latest.current;
|
||||
if (work.current !== job || current.query !== job.query || current.untitledLabel !== job.untitledLabel) return;
|
||||
const filteredSessions = job.scan.step(current.sessions);
|
||||
if (filteredSessions === null) {
|
||||
timer.current = setTimeout(run, 0);
|
||||
} else {
|
||||
setResult({ ...current, filteredSessions });
|
||||
}
|
||||
};
|
||||
// Even the first slice runs after the input's render/commit has returned.
|
||||
// Session updates reuse the in-flight query's match decisions, so one
|
||||
// streaming session cannot restart and starve a large history search.
|
||||
timer.current = setTimeout(run, 0);
|
||||
}, [sessions, query, untitledLabel, search]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timer.current !== null) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
work.current?.scan.dispose();
|
||||
work.current = null;
|
||||
search.clearSessionHistorySearchCache();
|
||||
}, [search]);
|
||||
return search;
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!query.trim()) return sessions;
|
||||
if (result?.query !== query || result.untitledLabel !== untitledLabel) return [];
|
||||
if (result.sessions === sessions) return result.filteredSessions;
|
||||
// Keep only confirmed, unchanged objects in the current order while the
|
||||
// scheduled scan checks new/replaced sessions. Do not read message bodies
|
||||
// here, or make the whole list disappear on each streaming update.
|
||||
const confirmed = new Set(result.filteredSessions);
|
||||
return sessions.filter((session) => confirmed.has(session));
|
||||
}, [sessions, query, untitledLabel, result]);
|
||||
const sameQuery = result?.query === query && result.untitledLabel === untitledLabel;
|
||||
return {
|
||||
filteredSessions,
|
||||
isSearching: Boolean(query.trim()) && (!sameQuery || result.sessions !== sessions),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import type { AISession } from "../infrastructure/ai/types.ts";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("history input yields before indexing long sessions and never publishes an obsolete query", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => { await renderer.unmount(); env.cleanup(); });
|
||||
|
||||
let heartbeat = false;
|
||||
let readsBeforeHeartbeat = 0;
|
||||
let reads = 0;
|
||||
const body = "representative retained output and ordinary content ".repeat(400);
|
||||
let sessions: AISession[] = Array.from({ length: 180 }, (_, index) => ({
|
||||
id: `session-${index}`,
|
||||
title: `Session ${index}`,
|
||||
agentId: "catty",
|
||||
scope: { type: "terminal", targetId: "fixture" },
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
messages: Array.from({ length: 4 }, (_, part) => ({
|
||||
id: `message-${index}-${part}`,
|
||||
role: "assistant" as const,
|
||||
timestamp: 0,
|
||||
get content() {
|
||||
reads += 1;
|
||||
if (!heartbeat) readsBeforeHeartbeat += 1;
|
||||
return index === 179 && part === 3 ? "latest-match" : body;
|
||||
},
|
||||
})),
|
||||
}));
|
||||
const noop = () => {};
|
||||
const render = () => renderer.render(
|
||||
<I18nProvider locale="en"><TooltipProvider>
|
||||
<SessionHistoryDrawer sessions={sessions} activeSessionId={null} onSelect={noop} onDelete={noop} onClose={noop} />
|
||||
</TooltipProvider></I18nProvider>,
|
||||
);
|
||||
const query = async (value: string) => {
|
||||
const input = renderer.container.querySelector("input")!;
|
||||
Object.getOwnPropertyDescriptor(env.window.HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
await dispatchDomEvent(input, new env.window.Event("input", { bubbles: true }));
|
||||
assert.equal(input.value, value);
|
||||
};
|
||||
const waitFor = async (condition: () => boolean) => {
|
||||
for (let attempt = 0; attempt < 300 && !condition(); attempt += 1) {
|
||||
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
}
|
||||
assert.ok(condition(), "search did not settle");
|
||||
};
|
||||
|
||||
await render();
|
||||
assert.equal(reads, 0, "blank query never indexes message bodies");
|
||||
setTimeout(() => { heartbeat = true; }, 0);
|
||||
await query("never-match");
|
||||
assert.equal(readsBeforeHeartbeat, 0, "typing must return to the event loop before indexing messages");
|
||||
await query("latest-match");
|
||||
await waitFor(() => renderer.container.querySelectorAll('[role="button"]').length === 1);
|
||||
assert.match(renderer.container.querySelector('[role="button"]')!.textContent!, /Session 179/);
|
||||
|
||||
// A streaming session changes while a large search is running. Keep progress
|
||||
// on unchanged sessions, search the replacement, and respect the new order.
|
||||
await query("new-live-match");
|
||||
sessions = [{ ...sessions[10], title: "New live match", messages: [{ id: "replacement", role: "assistant", content: "new-live-match", timestamp: 1 }] }, ...sessions.filter((session) => session.id !== "session-10" && session.id !== "session-179")];
|
||||
await render();
|
||||
await waitFor(() => renderer.container.querySelector('[role="button"]')?.textContent?.includes("New live match") === true);
|
||||
assert.equal(renderer.container.querySelectorAll('[role="button"]').length, 1);
|
||||
await query("");
|
||||
assert.equal(renderer.container.querySelectorAll('[role="button"]').length, 80);
|
||||
});
|
||||
|
||||
test("pending history updates immediately remove stale matches and retain current ordering", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => { await renderer.unmount(); env.cleanup(); });
|
||||
const make = (id: string, matches: boolean): AISession => ({
|
||||
id, title: id, agentId: "catty", scope: { type: "terminal", targetId: "fixture" },
|
||||
createdAt: 0, updatedAt: 0,
|
||||
messages: [{ id: `${id}-message`, role: "assistant", content: matches ? "needle" : "ordinary", timestamp: 0 }],
|
||||
});
|
||||
const a = make("Match A", true);
|
||||
const b = make("Match B", true);
|
||||
const c = make("Match C", true);
|
||||
const rest = Array.from({ length: 100 }, (_, index) => make(`Other ${index}`, false));
|
||||
let sessions = [a, b, c, ...rest];
|
||||
let revision = 0;
|
||||
const commits: string[][] = [];
|
||||
const rowLabels = () => [...renderer.container.querySelectorAll('[role="button"]')]
|
||||
.map((row) => row.querySelector("span")?.textContent ?? "");
|
||||
const noop = () => {};
|
||||
const Frame = ({ items, version }: { items: AISession[]; version: number }) => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (version > 0) commits.push(rowLabels());
|
||||
}, [items, version]);
|
||||
return <SessionHistoryDrawer sessions={items} activeSessionId={null} onSelect={noop} onDelete={noop} onClose={noop} />;
|
||||
};
|
||||
const render = () => renderer.render(<I18nProvider locale="en"><TooltipProvider>
|
||||
<Frame items={sessions} version={revision} />
|
||||
</TooltipProvider></I18nProvider>);
|
||||
const settle = async (expected: string[]) => {
|
||||
for (let attempt = 0; attempt < 200 && renderer.container.querySelector('[aria-busy="true"]'); attempt += 1) {
|
||||
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
}
|
||||
assert.deepEqual(rowLabels(), expected);
|
||||
};
|
||||
await render();
|
||||
const input = renderer.container.querySelector("input")!;
|
||||
Object.getOwnPropertyDescriptor(env.window.HTMLInputElement.prototype, "value")!.set!.call(input, "needle");
|
||||
await dispatchDomEvent(input, new env.window.Event("input", { bubbles: true }));
|
||||
await settle(["Match A", "Match B", "Match C"]);
|
||||
|
||||
revision += 1;
|
||||
sessions = [c, a, b, ...rest];
|
||||
await render();
|
||||
assert.deepEqual(commits.at(-1), ["Match C", "Match A", "Match B"], "pending results follow the current list order in the very first commit");
|
||||
await settle(["Match C", "Match A", "Match B"]);
|
||||
|
||||
revision += 1;
|
||||
sessions = [c, make("Match A", false), ...rest];
|
||||
await render();
|
||||
assert.deepEqual(commits.at(-1), ["Match C"], "deleted and replaced objects cannot remain selectable; unchanged matches stay visible");
|
||||
await settle(["Match C"]);
|
||||
|
||||
revision += 1;
|
||||
sessions = [make("Match D", true), ...sessions];
|
||||
await render();
|
||||
assert.deepEqual(commits.at(-1), ["Match C"]);
|
||||
await settle(["Match D", "Match C"]);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
|
||||
import type { AISession } from "../infrastructure/ai/types.ts";
|
||||
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
@@ -51,6 +51,10 @@ test("history drawer filters, pages, clears and explains the bounded search in e
|
||||
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setValue.call(input, query);
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
for (let attempt = 0; attempt < 100 && renderer.container.querySelector('[aria-busy="true"]'); attempt += 1) {
|
||||
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
}
|
||||
assert.equal(renderer.container.querySelector('[aria-busy="true"]'), null, "search must settle");
|
||||
};
|
||||
const loadMore = () => [...renderer.container.querySelectorAll("button")]
|
||||
.find((button) => button.textContent?.includes("Load more"));
|
||||
|
||||
@@ -37,15 +37,10 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchHintId = useId();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
// One cache instance per mounted drawer: caches of concurrently mounted
|
||||
// drawers stay independent (no shared state to prune or discard across tabs).
|
||||
const search = useSessionHistorySearch();
|
||||
|
||||
const filteredSessions = React.useMemo(
|
||||
// The localized fallback below is displayed for untitled sessions
|
||||
// (row label), so it must be indexed for search as the displayed title.
|
||||
() => search.filterSessionHistory(sessions, searchQuery, { untitledLabel: t('ai.chat.untitled') }),
|
||||
[sessions, searchQuery, t, search],
|
||||
const { filteredSessions, isSearching } = useSessionHistorySearch(
|
||||
sessions,
|
||||
searchQuery,
|
||||
t('ai.chat.untitled'),
|
||||
);
|
||||
|
||||
// Reset the render batch when the list scope or query changes so matching
|
||||
@@ -103,7 +98,12 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
{t('ai.chat.searchSessionsHint')}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="flex-1" aria-busy={isSearching}>
|
||||
{isSearching && (
|
||||
<p role="status" className="px-4 py-2 text-[12px] text-muted-foreground/60">
|
||||
{t('ai.chat.searchingSessions')}
|
||||
</p>
|
||||
)}
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
@@ -111,7 +111,7 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
) : filteredSessions.length === 0 && !isSearching ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noMatchingSessions')}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("expanded folder history bounds mounted files, isolates progress, and reaches the final child", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries({
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
NodeFilter: env.window.NodeFilter,
|
||||
HTMLInputElement: env.window.HTMLInputElement,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
})) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
Object.defineProperties(env.window.HTMLElement.prototype, {
|
||||
offsetWidth: { configurable: true, get: () => 460 },
|
||||
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-child-list" ? 192 : 460; } },
|
||||
});
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
const childCount = 1_000;
|
||||
const base = {
|
||||
fileName: "folder", sourcePath: "/source", targetPath: "/target",
|
||||
sourceConnectionId: "source", targetConnectionId: "local", direction: "download" as const,
|
||||
totalBytes: 1_000, transferredBytes: 0, speed: 0, startTime: 1,
|
||||
};
|
||||
store.publishOwner("children-performance", [
|
||||
{ ...base, id: "folder", status: "transferring", isDirectory: true, progressMode: "files" },
|
||||
...Array.from({ length: childCount }, (_, index) => ({
|
||||
...base, id: `child-${index}`, fileName: `child-${index}.bin`, sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
|
||||
status: index === 0 ? "transferring" as const : "queued" as const,
|
||||
startTime: index + 2, isDirectory: false, parentTaskId: "folder",
|
||||
})),
|
||||
]);
|
||||
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
|
||||
const click = async (selector: string) => {
|
||||
const element = env.document.querySelector(selector);
|
||||
assert.ok(element, selector);
|
||||
await dispatchDomEvent(element, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
};
|
||||
await click("[data-section=global-sftp-transfer-toggle]");
|
||||
await click('button[aria-label="Show files"]');
|
||||
const childList = () => env.document.querySelector<HTMLElement>("[data-section=global-sftp-transfer-child-list]");
|
||||
const childRows = () => childList()?.querySelectorAll('[data-transfer-status]') ?? [];
|
||||
assert.ok(childRows().length > 0 && childRows().length < 40, `mounted ${childRows().length} child rows`);
|
||||
assert.match(childList()?.textContent ?? "", /child-0.bin/);
|
||||
|
||||
let orderReads = 0;
|
||||
for (const task of store.getSnapshot().tasks) {
|
||||
if (!task.parentTaskId) continue;
|
||||
const startTime = task.startTime;
|
||||
Object.defineProperty(task, "startTime", { configurable: true, enumerable: true, get: () => { orderReads += 1; return startTime; } });
|
||||
}
|
||||
await runWithAct(async () => {
|
||||
store.patchTask("child-0", { transferredBytes: 500, speed: 100 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
assert.ok(orderReads < 20, `one byte update re-sorted the directory (${orderReads} reads)`);
|
||||
assert.match(childList()?.textContent ?? "", /500 B/);
|
||||
|
||||
const pauseAll = () => [...env.document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === "Pause all")!;
|
||||
assert.equal(pauseAll().disabled, false);
|
||||
await runWithAct(async () => {
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 600, resumable: false, pauseUnavailableReason: "fixture cannot pause" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
assert.equal(store.getTask("folder")?.resumable, false, "a child's backend capability also limits its folder");
|
||||
assert.equal(pauseAll().disabled, true, "batch controls must use the current backend capability");
|
||||
await runWithAct(async () => {
|
||||
store.patchTask("folder", { resumable: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
assert.equal(pauseAll().disabled, false, "metadata-only patches must refresh eligibility too");
|
||||
await runWithAct(async () => {
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 650, resumable: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
assert.equal(pauseAll().disabled, true, "a parent-only capability change is reflected even when the child already reported false");
|
||||
await runWithAct(async () => {
|
||||
store.patchTask("folder", { resumable: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
assert.equal(pauseAll().disabled, true);
|
||||
orderReads = 0;
|
||||
await runWithAct(async () => {
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 700, resumable: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
assert.ok(orderReads < 20, "repeated capability metadata on byte ticks does not re-sort the directory");
|
||||
await runWithAct(() => store.publishOwner("failed-control", [{
|
||||
...base, id: "failed-control", status: "failed", isDirectory: false, endTime: Date.now(), resumable: true, checkpointBytes: 0,
|
||||
}]));
|
||||
const resumeAll = () => [...env.document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === "Resume all")!;
|
||||
assert.equal(resumeAll().disabled, true);
|
||||
await runWithAct(async () => {
|
||||
store.patchTask("failed-control", { checkpointBytes: 20 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
assert.equal(resumeAll().disabled, false, "a newly available failed-task checkpoint enables the existing resume action");
|
||||
|
||||
const list = childList()!;
|
||||
list.scrollTop = 24 * childCount - 192;
|
||||
await dispatchDomEvent(list, new env.window.Event("scroll"));
|
||||
await flushEffects();
|
||||
assert.ok(childRows().length > 0 && childRows().length < 40);
|
||||
assert.match(childList()?.textContent ?? "", /child-999.bin/);
|
||||
await runWithAct(() => store.patchTask("child-999", { status: "completed", transferredBytes: 1_000 }));
|
||||
assert.equal(store.getTask("folder")?.directoryResumeCheckpoint?.completedEntries, 1, "completed files remain counted in the existing compact folder checkpoint");
|
||||
assert.equal(store.getTask("child-999"), undefined, "the existing completion compaction still owns history retention");
|
||||
assert.equal(store.getTask("child-500")?.status, "queued");
|
||||
await click('button[aria-label="Hide files"]');
|
||||
assert.equal(childList(), null);
|
||||
await click('button[aria-label="Show files"]');
|
||||
assert.ok(childRows().length > 0 && childRows().length < 40);
|
||||
assert.equal(store.getSnapshot().tasks.filter((task) => task.parentTaskId === "folder").length, childCount - 1, "virtualization never drops queued files");
|
||||
await runWithAct(() => {
|
||||
store.publishOwner("repair-folder", [{ ...base, id: "repair-folder", fileName: "repaired folder", status: "transferring", isDirectory: true }]);
|
||||
store.publishOwner("repair-background", [{ ...base, id: "repair-child", fileName: "repaired-child.bin", status: "transferring", isDirectory: false, background: true, transferredBytes: 10 }]);
|
||||
});
|
||||
const repairedFolderRow = () => [...env.document.querySelectorAll<HTMLElement>('[data-section=global-sftp-transfer-row]')].find((row) => row.textContent?.includes("repaired folder"))!;
|
||||
assert.equal(repairedFolderRow().querySelector('button[aria-label="Show files"]'), null);
|
||||
await runWithAct(async () => {
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "repair-child", parentTaskId: "repair-folder", transferred: 10 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
const repairedExpand = repairedFolderRow().querySelector('button[aria-label="Show files"]');
|
||||
assert.ok(repairedExpand, "same-byte hierarchy repair immediately exposes the folder's child");
|
||||
await dispatchDomEvent(repairedExpand, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
assert.match(repairedFolderRow().textContent ?? "", /repaired-child.bin/);
|
||||
const repairedHierarchy = store.getLifecycleSnapshot();
|
||||
await runWithAct(async () => {
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: "repair-child", parentTaskId: "repair-folder", transferred: 11 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
assert.equal(store.getLifecycleSnapshot(), repairedHierarchy);
|
||||
assert.match(repairedFolderRow().textContent ?? "", /repaired-child.bin/);
|
||||
await click("[data-section=global-sftp-transfer-toggle]");
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
sftpTransferCenterStore,
|
||||
useSftpTransferCenterBadge,
|
||||
useSftpTransferResuming,
|
||||
useSftpTransferTask,
|
||||
type SftpTransferCenterSnapshot,
|
||||
} from "../application/state/sftpTransferCenterStore";
|
||||
import { transferRuntime } from "../application/state/sftp/transferRuntime";
|
||||
@@ -47,28 +48,22 @@ const CLOSED_TRANSFER_CENTER_SNAPSHOT: SftpTransferCenterSnapshot = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Full transfer-center list only while the popover is open. When closed, the
|
||||
* getter always returns the same CLOSED snapshot so progress store notifies do
|
||||
* not re-render this TopTabs child (badge uses a separate stable subscription).
|
||||
* Read the folder hierarchy only while open and only on lifecycle changes.
|
||||
* Mounted rows subscribe to their own bytes; progress must not regroup/sort
|
||||
* every folder or re-render the center and its TopTabs ancestor.
|
||||
*/
|
||||
function useSftpTransferCenterWhenOpen(open: boolean): SftpTransferCenterSnapshot {
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => {
|
||||
// Lifecycle + progress: when the popover is open the list must move with
|
||||
// bytes; when closed getSnapshot returns a stable CLOSED constant so
|
||||
// progress ticks are cheap no-ops for this subscriber.
|
||||
const unsubLifecycle = sftpTransferCenterStore.subscribe(onStoreChange);
|
||||
const unsubProgress = sftpTransferCenterStore.subscribeProgress(onStoreChange);
|
||||
return () => {
|
||||
unsubLifecycle();
|
||||
unsubProgress();
|
||||
};
|
||||
const unsubscribeLifecycle = sftpTransferCenterStore.subscribe(onStoreChange);
|
||||
const unsubscribeProgress = sftpTransferCenterStore.subscribeProgress(onStoreChange);
|
||||
return () => { unsubscribeLifecycle(); unsubscribeProgress(); };
|
||||
},
|
||||
() => (
|
||||
openRef.current
|
||||
? sftpTransferCenterStore.getSnapshot()
|
||||
? sftpTransferCenterStore.getLifecycleSnapshot()
|
||||
: CLOSED_TRANSFER_CENTER_SNAPSHOT
|
||||
),
|
||||
() => CLOSED_TRANSFER_CENTER_SNAPSHOT,
|
||||
@@ -340,8 +335,74 @@ function formatTransferPathLine(task: Pick<TransferTask, "sourcePath" | "targetP
|
||||
return `${source} → ${target}`;
|
||||
}
|
||||
|
||||
const TransferChildRow = React.memo(function TransferChildRow({ task: snapshotTask, compact = false, frozen = false }: {
|
||||
task: TransferTask;
|
||||
compact?: boolean;
|
||||
frozen?: boolean;
|
||||
}) {
|
||||
const liveTask = useSftpTransferTask(snapshotTask.id, snapshotTask);
|
||||
const task = frozen ? snapshotTask : liveTask;
|
||||
const { t } = useI18n();
|
||||
const percent = getGlobalTransferProgressPercent(task);
|
||||
const detail = formatChildByteProgress(task)
|
||||
|| (task.totalBytes > 0 ? `${percent.toFixed(0)}%` : t(statusLabelKey(task.status)));
|
||||
if (!compact) {
|
||||
return (
|
||||
<div className="flex h-6 items-center gap-2 rounded px-1 text-[10px] hover:bg-muted/40" data-transfer-status={task.status}>
|
||||
<TruncatedTextWithTooltip text={task.fileName} tooltip={task.targetPath || task.fileName} className="flex-1 text-muted-foreground" />
|
||||
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
|
||||
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const childFrozen = frozen || task.status === "pausing" || task.status === "paused";
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
|
||||
<TruncatedTextWithTooltip text={task.fileName} className="flex-1 text-muted-foreground" />
|
||||
<span className="shrink-0 font-mono">{detail}</span>
|
||||
</div>
|
||||
{task.totalBytes > 0 && (
|
||||
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-secondary">
|
||||
<div className={cn("h-full rounded-full", task.status === "completed" ? "bg-emerald-500/80" : childFrozen ? "bg-amber-500/80" : "bg-primary/80")}
|
||||
style={{ width: `${task.status === "completed" ? 100 : percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TransferChildrenList = React.memo(function TransferChildrenList({ tasks }: { tasks: readonly TransferTask[] }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const virtual = tasks.length > 20;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: tasks.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 24,
|
||||
getItemKey: (index) => tasks[index].id,
|
||||
initialRect: { width: 0, height: 192 },
|
||||
overscan: 4,
|
||||
enabled: virtual,
|
||||
});
|
||||
return (
|
||||
<div className="mt-2 border-t border-border/40 pt-2">
|
||||
<div ref={scrollRef} className="max-h-48 overflow-y-auto" style={{ height: Math.min(tasks.length, 8) * 24 }} data-section="global-sftp-transfer-child-list">
|
||||
{!virtual ? tasks.map((task) => <TransferChildRow key={task.id} task={task} />) : (
|
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((row) => (
|
||||
<div key={row.key} className="absolute left-0 top-0 w-full" style={{ transform: `translateY(${row.start}px)` }}>
|
||||
<TransferChildRow task={tasks[row.index]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function TransferRow({
|
||||
task,
|
||||
task: snapshotTask,
|
||||
childTasks = [],
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
@@ -353,6 +414,7 @@ function TransferRow({
|
||||
onToggleExpanded: () => void;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const task = useSftpTransferTask(snapshotTask.id, snapshotTask);
|
||||
const { t } = useI18n();
|
||||
const folderReplaceWarningId = React.useId();
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
@@ -605,72 +667,14 @@ function TransferRow({
|
||||
)}
|
||||
data-section="global-sftp-transfer-active-children"
|
||||
>
|
||||
{displayChildren.map((child) => {
|
||||
const childPercent = getGlobalTransferProgressPercent(child);
|
||||
const childDetail = formatChildByteProgress(child);
|
||||
const childFrozen = isPausing
|
||||
|| task.status === "paused"
|
||||
|| child.status === "pausing"
|
||||
|| child.status === "paused";
|
||||
return (
|
||||
<div key={child.id} className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
|
||||
<TruncatedTextWithTooltip text={child.fileName} className="flex-1 text-muted-foreground" />
|
||||
<span className="shrink-0 font-mono">
|
||||
{childDetail || (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
|
||||
</span>
|
||||
</div>
|
||||
{child.totalBytes > 0 && (
|
||||
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
child.status === "completed"
|
||||
? "bg-emerald-500/80"
|
||||
: childFrozen
|
||||
? "bg-amber-500/80"
|
||||
: "bg-primary/80",
|
||||
)}
|
||||
style={{ width: `${child.status === "completed" ? 100 : childPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{displayChildren.map((child) => (
|
||||
<TransferChildRow key={child.id} task={child} compact frozen={isPausing || task.status === "paused"} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDirParent && expanded && childTasks.length > 0 && (
|
||||
<div
|
||||
className="mt-2 max-h-48 space-y-1 overflow-y-auto border-t border-border/40 pt-2"
|
||||
data-section="global-sftp-transfer-child-list"
|
||||
>
|
||||
{childTasks.map((child) => {
|
||||
const childPercent = getGlobalTransferProgressPercent(child);
|
||||
const childDetail = formatChildByteProgress(child);
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center gap-2 rounded px-1 py-1 text-[10px] hover:bg-muted/40"
|
||||
data-transfer-status={child.status}
|
||||
>
|
||||
<TruncatedTextWithTooltip
|
||||
text={child.fileName}
|
||||
tooltip={child.targetPath || child.fileName}
|
||||
className="flex-1 text-muted-foreground"
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-muted-foreground">
|
||||
{childDetail
|
||||
|| (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
|
||||
</span>
|
||||
{child.status === "transferring" && (
|
||||
<Loader2 size={10} className="shrink-0 animate-spin text-primary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TransferChildrenList tasks={childTasks} />
|
||||
)}
|
||||
|
||||
{task.status === "attention" && task.conflict && canControl && (() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("large transfer histories mount only visible rows and can scroll to the last file", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
@@ -64,6 +64,33 @@ test("large transfer histories mount only visible rows and can scroll to the las
|
||||
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
|
||||
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
|
||||
await runWithAct(() => {
|
||||
store.patchTask("virtual-file-999", { isDirectory: true, progressMode: "files" });
|
||||
store.upsertTasks(Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `nested-file-${index}`, fileName: `nested-${index}.bin`,
|
||||
sourcePath: `/source/999/${index}`, targetPath: `/target/999/${index}`,
|
||||
sourceConnectionId: "source", targetConnectionId: "local", direction: "download" as const,
|
||||
status: "queued" as const, totalBytes: 1_000, transferredBytes: 0, speed: 0,
|
||||
startTime: index + 2_000, isDirectory: false, parentTaskId: "virtual-file-999",
|
||||
})));
|
||||
});
|
||||
scroll.scrollTop = 0;
|
||||
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
|
||||
await flushEffects();
|
||||
const expand = env.document.querySelector('button[aria-label="Show files"]');
|
||||
assert.ok(expand);
|
||||
await dispatchDomEvent(expand, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
assert.ok(env.document.querySelector('[data-section="global-sftp-transfer-child-list"]'));
|
||||
scroll.scrollTop = 112 * 1_000 - 460;
|
||||
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
|
||||
await flushEffects();
|
||||
scroll.scrollTop = 0;
|
||||
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
|
||||
await flushEffects();
|
||||
assert.ok(env.document.querySelector('button[aria-label="Hide files"]'), "folder expansion survives top-level row recycling");
|
||||
assert.ok(env.document.querySelector('[data-section="global-sftp-transfer-child-list"]'));
|
||||
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
});
|
||||
@@ -1,16 +1,137 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ArrowRight, Bookmark, Download, Edit2, Loader2, Plus, RotateCw, Square, Trash2, X } from 'lucide-react';
|
||||
import { useHostBrowserPanel } from '../application/state/useHostBrowserPanel';
|
||||
import { useHostBrowserControls } from '../application/state/useHostBrowserControls';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { BrowserBookmark } from '../domain/browserBookmark';
|
||||
import type { HostBrowserTab } from '../domain/hostBrowser';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
||||
import { BrowserIcon } from './host-browser/BrowserIcon';
|
||||
import { BrowserBookmarkEditor } from './host-browser/BrowserBookmarkEditor';
|
||||
import { BrowserDownloads } from './host-browser/BrowserDownloads';
|
||||
import { BrowserIconEditor, type BrowserIconSelection } from './host-browser/BrowserIconEditor';
|
||||
|
||||
function ToolbarButton({ label, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
|
||||
return <Tooltip><TooltipTrigger asChild><button type="button" aria-label={label} {...props} className={cn('grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-35', props.className)}>{children}</button></TooltipTrigger><TooltipContent side="top">{label}</TooltipContent></Tooltip>;
|
||||
}
|
||||
|
||||
export function HostBrowserSidePanel(props: Parameters<typeof useHostBrowserPanel>[0]) {
|
||||
const { elementRef, error, retry } = useHostBrowserPanel(props);
|
||||
const { elementRef, error: mountError, snapshot, retry, ready } = useHostBrowserPanel(props);
|
||||
const browser = useHostBrowserControls(props.browserId, ready && props.isVisible);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div ref={elementRef} className="relative h-full min-h-0 w-full overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
|
||||
{error && <div className="p-4 text-sm text-muted-foreground">
|
||||
<p>{error}</p>
|
||||
<button type="button" className="mt-3 text-primary" onClick={retry}>{t('common.retry')}</button>
|
||||
</div>}
|
||||
const active = browser.state?.tabs.find((tab) => tab.id === browser.state?.activeTabId);
|
||||
const connected = browser.state?.route === 'connected';
|
||||
const [address, setAddress] = useState('');
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const lastActive = useRef<string | undefined>(undefined);
|
||||
const [bookmarksOpen, setBookmarksOpen] = useState(false);
|
||||
const [downloadsOpen, setDownloadsOpen] = useState(false);
|
||||
const [editingBookmark, setEditingBookmark] = useState<Partial<BrowserBookmark> | null>(null);
|
||||
const [editingIcon, setEditingIcon] = useState<HostBrowserTab | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastActive.current !== active?.id || document.activeElement !== addressRef.current) setAddress(active?.url || '');
|
||||
lastActive.current = active?.id;
|
||||
}, [active?.id, active?.url]);
|
||||
useEffect(() => {
|
||||
if (!browser.focusAddress) return;
|
||||
addressRef.current?.focus(); addressRef.current?.select();
|
||||
}, [browser.focusAddress]);
|
||||
useEffect(() => { setEditingBookmark(null); setEditingIcon(null); setBookmarksOpen(false); setDownloadsOpen(false); }, [props.browserId]);
|
||||
useEffect(() => {
|
||||
if (browser.downloadStarted) { setDownloadsOpen(true); setBookmarksOpen(false); }
|
||||
}, [browser.downloadStarted]);
|
||||
|
||||
const newBookmark = () => {
|
||||
setBookmarksOpen(true);
|
||||
setEditingBookmark({ title: active?.url ? active.title || '' : '', url: active?.url || '', scope: 'host', color: '#247969', order: browser.bookmarks.length });
|
||||
};
|
||||
const navigate = (url: string) => { void browser.command({ type: 'navigate', url }); };
|
||||
const error = mountError || browser.error;
|
||||
return <div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background text-foreground" data-section="host-browser-root" onKeyDownCapture={(event) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (!['l', 't', 'w', 'r'].includes(key)) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (key === 'l') browser.requestAddressFocus();
|
||||
if (key === 't') void browser.command({ type: 'new-tab' });
|
||||
if (key === 'w') void browser.command({ type: 'close-tab' });
|
||||
if (key === 'r') void browser.command({ type: 'reload' });
|
||||
}}>
|
||||
<div className="shrink-0 border-b border-border/50" data-section="host-browser-controls">
|
||||
<div className="flex min-w-0 items-center gap-0.5 px-1.5 pt-1">
|
||||
<div role="tablist" aria-label={t('terminal.browser.title')} className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto">
|
||||
{browser.state?.tabs.map((tab) => <ContextMenu key={tab.id}><ContextMenuTrigger asChild><div role="tab" tabIndex={tab.id === active?.id ? 0 : -1} aria-selected={tab.id === active?.id} draggable
|
||||
className={cn('group flex h-7 min-w-[90px] max-w-[200px] flex-1 cursor-default items-center gap-1.5 rounded px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring', tab.id === active?.id ? 'bg-secondary/65 text-foreground' : 'text-muted-foreground hover:bg-secondary/30')}
|
||||
onClick={() => void browser.command({ type: 'activate', tabId: tab.id })}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void browser.command({ type: 'activate', tabId: tab.id }); } }}
|
||||
onDragStart={(event) => { event.dataTransfer.setData('text/plain', tab.id); event.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={(event) => { event.preventDefault(); const id = event.dataTransfer.getData('text/plain'); if (browser.state?.tabs.some((item) => item.id === id)) void browser.command({ type: 'reorder', tabId: id, beforeTabId: tab.id }); }}>
|
||||
{tab.loading ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <BrowserIcon image={tab.iconDataUrl} glyph={tab.iconGlyph} color={tab.iconColor} className="h-3.5 w-3.5" />}
|
||||
<span className="min-w-0 flex-1 truncate" title={tab.title || tab.url}>{tab.url ? tab.title || tab.url : t('terminal.browser.newTab')}</span>
|
||||
<button type="button" aria-label={t('terminal.browser.closeTab')} className="grid h-4 w-4 shrink-0 place-items-center rounded opacity-60 hover:bg-muted hover:opacity-100" onClick={(event) => { event.stopPropagation(); void browser.command({ type: 'close-tab', tabId: tab.id }); }}><X size={11} /></button>
|
||||
</div></ContextMenuTrigger><ContextMenuContent>
|
||||
<ContextMenuItem disabled={!tab.url} onClick={() => setEditingIcon(tab)}>{t('terminal.browser.changeIcon')}</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!tab.url} onClick={() => void browser.command({ type: 'reset-icon', tabId: tab.id })}>{t('terminal.browser.resetIcon')}</ContextMenuItem>
|
||||
</ContextMenuContent></ContextMenu>)}
|
||||
</div>
|
||||
<ToolbarButton label={t('terminal.browser.newTab')} disabled={!ready} onClick={() => void browser.command({ type: 'new-tab' })}><Plus size={14} /></ToolbarButton>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-0.5 px-1.5 py-1">
|
||||
<ToolbarButton label={t('terminal.browser.back')} disabled={!connected || !active?.canGoBack} onClick={() => void browser.command({ type: 'back' })}><ArrowLeft size={14} /></ToolbarButton>
|
||||
<ToolbarButton label={t('terminal.browser.forward')} disabled={!connected || !active?.canGoForward} onClick={() => void browser.command({ type: 'forward' })}><ArrowRight size={14} /></ToolbarButton>
|
||||
<ToolbarButton label={t(active?.loading ? 'terminal.browser.stop' : 'terminal.browser.reload')} disabled={!connected || !active?.url} onClick={() => void browser.command({ type: active?.loading ? 'stop' : 'reload' })}>{active?.loading ? <Square size={12} /> : <RotateCw size={14} />}</ToolbarButton>
|
||||
<form className="mx-1 min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); navigate(address); addressRef.current?.blur(); }}><Input ref={addressRef} aria-label={t('terminal.browser.address')} placeholder={t('terminal.browser.address')} value={address} onChange={(event) => setAddress(event.target.value)} autoComplete="off" spellCheck={false} className="h-7 min-w-0 rounded-md border-border/50 bg-secondary/35 px-2 text-xs" /></form>
|
||||
<ToolbarButton label={t('terminal.browser.bookmarks')} aria-pressed={bookmarksOpen} className={bookmarksOpen ? 'bg-secondary/70 text-foreground' : ''} onClick={() => { setBookmarksOpen((value) => !value); setDownloadsOpen(false); }}><Bookmark size={14} /></ToolbarButton>
|
||||
<Popover open={downloadsOpen} onOpenChange={setDownloadsOpen}><Tooltip><TooltipTrigger asChild><PopoverTrigger asChild><button type="button" aria-label={t('terminal.browser.downloads')} className="relative grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground"><Download size={14} />{browser.downloads.some((item) => ['waiting', 'progressing'].includes(item.status)) && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary" />}</button></PopoverTrigger></TooltipTrigger><TooltipContent side="top">{t('terminal.browser.downloads')}</TooltipContent></Tooltip><PopoverContent align="end" className="w-[min(380px,calc(100vw-24px))] p-0"><BrowserDownloads items={browser.downloads} command={browser.downloadCommand} /></PopoverContent></Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{error && <div role="status" className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground"><span className="min-w-0 flex-1">{error}</span>{mountError ? <Button size="sm" variant="ghost" onClick={retry}>{t('terminal.browser.retry')}</Button> : <button type="button" aria-label={t('common.close')} onClick={browser.dismissError}><X size={13} /></button>}</div>}
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div ref={elementRef} className="relative min-h-0 min-w-0 flex-1 overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
|
||||
{snapshot.map((layer, index) => <img key={index} src={layer.image} alt="" aria-hidden="true" draggable={false} className="absolute pointer-events-none select-none" style={{ left: layer.bounds.x, top: layer.bounds.y, width: layer.bounds.width, height: layer.bounds.height }} />)}
|
||||
{!connected ? <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center text-xs text-muted-foreground"><p>{t(browser.state?.route === 'disconnected' ? 'terminal.browser.disconnected' : 'terminal.browser.connecting')}</p>{browser.state?.error && <p>{browser.state.error}</p>}{browser.state?.route === 'disconnected' && <Button size="sm" variant="secondary" onClick={() => void browser.command({ type: 'retry' })}>{t('terminal.browser.retry')}</Button>}</div>
|
||||
: !active?.url && <div className="h-full overflow-y-auto p-4"><div className="mb-3 flex items-center justify-between gap-2 text-xs text-muted-foreground"><span>{t('terminal.browser.bookmarks')}</span><Button size="sm" variant="ghost" className="h-7 text-xs" onClick={newBookmark}><Plus size={12} className="mr-1" />{t('terminal.browser.addBookmark')}</Button></div>{!browser.bookmarks.length ? <p className="py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.empty')}</p> : <div className="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-1">{browser.bookmarks.map((item) => <button type="button" key={item.id} title={item.url} className="flex min-w-0 flex-col items-center gap-2 rounded-lg p-3 hover:bg-secondary/50" onClick={() => navigate(item.url)}><BrowserIcon className="h-9 w-9 rounded-lg bg-secondary/60 p-1.5" image={item.iconMode === 'custom' ? item.iconDataUrl : item.siteIconDataUrl} glyph={item.iconMode === 'custom' ? item.icon : item.siteIconGlyph || item.icon} color={item.color} /><span className="w-full truncate text-xs">{item.title}</span></button>)}</div>}</div>}
|
||||
</div>
|
||||
{bookmarksOpen && <aside className="flex w-[380px] max-w-full shrink-0 flex-col border-l border-border/50 bg-background" aria-label={t('terminal.browser.bookmarks')}>
|
||||
<div className="flex min-h-12 shrink-0 items-center justify-between gap-2 border-b border-border/60 px-4 py-3"><span className="truncate text-sm font-semibold">{t(editingBookmark ? editingBookmark.id ? 'terminal.browser.editBookmark' : 'terminal.browser.addBookmark' : 'terminal.browser.bookmarks')}</span><div className="flex items-center gap-1">{!editingBookmark && <ToolbarButton label={t('terminal.browser.addBookmark')} onClick={newBookmark}><Plus size={13} /></ToolbarButton>}<ToolbarButton label={t('common.close')} onClick={() => setBookmarksOpen(false)}><X size={13} /></ToolbarButton></div></div>
|
||||
{editingBookmark ? <BrowserBookmarkEditor key={editingBookmark.id || 'new'} initial={editingBookmark} onSave={browser.mutateBookmark} onCancel={() => setEditingBookmark(null)} readIcon={browser.readIcon} /> : <div className="min-h-0 flex-1 overflow-y-auto p-1">{browser.bookmarks.map((item) => <div key={item.id} className="group flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/40"><BrowserIcon className="h-6 w-6 rounded" image={item.iconMode === 'custom' ? item.iconDataUrl : item.siteIconDataUrl} glyph={item.iconMode === 'custom' ? item.icon : item.siteIconGlyph || item.icon} color={item.color} /><button type="button" className="min-w-0 flex-1 text-left" onClick={() => navigate(item.url)}><p className="truncate text-xs">{item.title}</p><p className="truncate text-[11px] text-muted-foreground">{item.url}</p></button><ToolbarButton label={t('common.edit')} onClick={() => setEditingBookmark(item)}><Edit2 size={12} /></ToolbarButton><ToolbarButton label={t('common.delete')} onClick={() => void browser.mutateBookmark(item.id)}><Trash2 size={12} /></ToolbarButton></div>)}</div>}
|
||||
</aside>}
|
||||
</div>
|
||||
{editingIcon && <BrowserTabIconEditor key={editingIcon.id} tab={editingIcon} readIcon={browser.readIcon} onClose={() => setEditingIcon(null)} onSave={async (values) => { const saved = await browser.command({ type: 'set-icon', tabId: editingIcon.id, ...values }); if (saved) setEditingIcon(null); }} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function BrowserTabIconEditor({ tab, readIcon, onClose, onSave }: { tab: HostBrowserTab; readIcon: (file: File) => Promise<string | null>; onClose: () => void; onSave: (values: { glyph: string; color: string; dataUrl?: string | null }) => Promise<void> }) {
|
||||
const { t } = useI18n();
|
||||
const [icon, setIcon] = useState<BrowserIconSelection>({ mode: tab.iconDataUrl ? 'image' : 'text', glyph: tab.iconGlyph || '', color: tab.iconColor || '#247969', image: tab.iconDataUrl });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reading, setReading] = useState(false);
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle>{t('terminal.browser.changeIcon')}</DialogTitle>
|
||||
<form className="space-y-4" onSubmit={async (event) => {
|
||||
event.preventDefault(); setSaving(true);
|
||||
try { await onSave({ glyph: icon.mode === 'text' ? icon.glyph : '', color: icon.color, dataUrl: icon.mode === 'image' ? icon.image : null }); }
|
||||
finally { setSaving(false); }
|
||||
}}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<BrowserIcon className="h-12 w-12 rounded-xl border border-border/50 bg-muted/40 p-2.5 [&>span]:text-lg" image={icon.mode === 'image' ? icon.image : null} glyph={icon.mode === 'text' ? icon.glyph : null} color={icon.color} />
|
||||
<p className="min-w-0 truncate text-sm font-medium">{tab.title || tab.url}</p>
|
||||
</div>
|
||||
<BrowserIconEditor value={icon} onChange={(changes) => setIcon((current) => ({ ...current, ...changes }))} readIcon={readIcon} onBusyChange={setReading} allowWebsite={false} />
|
||||
<div className="flex justify-end gap-2 border-t border-border/50 pt-3">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" size="sm" disabled={saving || reading || (icon.mode === 'image' ? !icon.image : !icon.glyph.trim())}>{t('common.save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
|
||||
test("stacked library continues loading after running a script and returning", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
const observers: Array<{ target: Element | null; intersect: () => void }> = [];
|
||||
class Observer {
|
||||
target: Element | null = null;
|
||||
constructor(private callback: IntersectionObserverCallback) { observers.push(this); }
|
||||
observe(target: Element) { this.target = target; }
|
||||
disconnect() { this.target = null; }
|
||||
intersect = () => {
|
||||
if (this.target?.isConnected) {
|
||||
this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
};
|
||||
}
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer });
|
||||
const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts");
|
||||
env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
env.cleanup();
|
||||
});
|
||||
const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({
|
||||
id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "",
|
||||
}));
|
||||
const executed: string[] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
|
||||
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
|
||||
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
|
||||
);
|
||||
const library = () => env.document.querySelector('[data-scripts-view="stacked"]');
|
||||
const chipCount = () => library()?.querySelectorAll("button").length;
|
||||
const button = (label: string) => {
|
||||
const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label);
|
||||
assert.ok(result, `missing button ${label}`);
|
||||
return result;
|
||||
};
|
||||
assert.equal(chipCount(), 120);
|
||||
const firstSentinel = observers.at(-1)?.target;
|
||||
assert.ok(firstSentinel?.isConnected);
|
||||
await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(executed, ["script-0"]);
|
||||
assert.equal(library(), null, "execution opens Running");
|
||||
assert.equal(firstSentinel.isConnected, false);
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 120);
|
||||
assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed");
|
||||
assert.notEqual(observers.at(-1)?.target, firstSentinel);
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 240);
|
||||
await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 240, "returning preserves the loaded window");
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 300);
|
||||
assert.ok(button("Script 299"));
|
||||
});
|
||||
@@ -174,31 +174,3 @@ test("scripts side panel does not start a drag from a non-primary pointer", () =
|
||||
assert.match(source, /isNonPrimaryPointer/);
|
||||
assert.match(source, /primaryOnlyDragHandlers/);
|
||||
});
|
||||
|
||||
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
|
||||
assert.match(source, /useScriptsViewMode/);
|
||||
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
|
||||
assert.ok(toggle, "view toggle button should swap list / stacked icons");
|
||||
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
|
||||
// The chosen mode persists via the shared hook so it survives app restarts.
|
||||
assert.match(source, /useScriptsViewMode\(\)/);
|
||||
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
|
||||
});
|
||||
|
||||
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
|
||||
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
|
||||
assert.ok(stacked, "stacked branch should exist in the content area");
|
||||
assert.match(stacked[0], /flex flex-wrap/);
|
||||
// Chips reuse the same click handler (multi-select / run / execute) as rows.
|
||||
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
|
||||
// Packages stay visible as section headers with their counts.
|
||||
assert.match(stacked[0], /item\.row\.name/);
|
||||
assert.match(stacked[0], /item\.countLabel/);
|
||||
assert.match(source, /const SnippetChip = memo/);
|
||||
// The stacked chip keeps the same context menu contract (edit / delete / run).
|
||||
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
|
||||
assert.ok(chip, "SnippetChip component should exist");
|
||||
assert.match(chip[0], /ContextMenuContent/);
|
||||
assert.match(chip[0], /onRunParallel/);
|
||||
assert.match(chip[0], /onEdit/);
|
||||
});
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
Expand,
|
||||
FolderPlus,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Minimize2,
|
||||
Package,
|
||||
Play,
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
|
||||
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import {
|
||||
collectSnippetPackageTreePaths,
|
||||
@@ -64,12 +61,6 @@ const toolbarIconButtonClass =
|
||||
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList,
|
||||
// so cap the initial render and grow lazily as the user scrolls to keep a huge
|
||||
// library (or a broad search) from mounting every chip at once.
|
||||
const STACKED_INITIAL_RENDER_COUNT = 120;
|
||||
const STACKED_RENDER_BATCH = 120;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return body.length > 0 && !body.includes('/');
|
||||
@@ -179,14 +170,10 @@ export function buildScriptsSidePanelRows({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
snippetsBeforeChildren = false,
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
expandedPaths: Set<string>;
|
||||
/** Emit each package's own snippets before its sub-packages so flat views
|
||||
* (stacked chips) keep every package's chips attached to its section. */
|
||||
snippetsBeforeChildren?: boolean;
|
||||
}): TreeRow[] {
|
||||
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
||||
|
||||
@@ -263,13 +250,6 @@ export function buildScriptsSidePanelRows({
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
if (snippetsBeforeChildren) {
|
||||
// Flat stacked view: a package's chips must follow its own section
|
||||
// header, not trail after its nested children's chips.
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
return;
|
||||
}
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
};
|
||||
@@ -303,7 +283,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
||||
const [viewMode, setViewMode] = useScriptsViewMode();
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
@@ -314,7 +293,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState('');
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [packageError, setPackageError] = useState('');
|
||||
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
|
||||
const packageDialogRef = useRef<HTMLDivElement>(null);
|
||||
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -509,11 +487,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
// Stacked chips wrap flat, so each package's chips must stay attached
|
||||
// to its own section header.
|
||||
snippetsBeforeChildren: viewMode === 'stacked',
|
||||
});
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
@@ -546,48 +521,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const stackedSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Restart the lazy window whenever the underlying result set changes
|
||||
// (new search, package list changes, snippet edits, view-mode switch) — but
|
||||
// NOT on package expand/collapse. Expanding/collapsing only reshapes the
|
||||
// same result set; resetting there would drop the user's lazy-loaded window
|
||||
// and remount the package they just toggled, forcing them to re-scroll.
|
||||
// Keyed by data identity, not by listItems reference, for that reason.
|
||||
// Reset during render — not in an effect — so the first render of a new
|
||||
// result set never slices with the previous, potentially very large window.
|
||||
const stackedResultSetKey = useMemo(() => {
|
||||
if (searchMatches !== null) {
|
||||
return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`;
|
||||
}
|
||||
return `tree:${viewMode}:${packages.join('\u0000')}:${snippets
|
||||
.map((snippet) => snippet.id)
|
||||
.join('\u0000')}`;
|
||||
}, [searchMatches, viewMode, packages, snippets]);
|
||||
|
||||
const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey);
|
||||
if (prevResultSetKey !== stackedResultSetKey) {
|
||||
setPrevResultSetKey(stackedResultSetKey);
|
||||
setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT);
|
||||
}
|
||||
|
||||
const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (subView !== 'library' || !hasMoreStackedItems) return;
|
||||
const sentinel = stackedSentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
// Re-created per batch: observing always emits an initial intersection
|
||||
// callback, so short result sets that never scroll keep loading.
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH);
|
||||
}
|
||||
});
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreStackedItems, stackedRenderLimit, subView]);
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
if (isMultiSelectMode) {
|
||||
@@ -1046,30 +979,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
disabled={!hasAnyContent}
|
||||
aria-label={viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
aria-pressed={viewMode === 'stacked'}
|
||||
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
|
||||
>
|
||||
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1217,107 +1126,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : viewMode === 'stacked' ? (
|
||||
<div
|
||||
className="h-full overflow-auto px-2 py-1.5"
|
||||
data-scripts-view="stacked"
|
||||
>
|
||||
<div className="flex flex-wrap items-center content-start gap-1.5">
|
||||
{listItems.slice(0, stackedRenderLimit).map((item) => {
|
||||
if (item.kind === 'package') {
|
||||
// Mirror the list view's PackageRow context menu so stacked
|
||||
// mode keeps rename/delete reachable without switching views.
|
||||
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
|
||||
const header = (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => togglePackage(item.row.path)}
|
||||
aria-expanded={item.row.isExpanded}
|
||||
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
|
||||
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={11}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
item.row.isExpanded && 'rotate-90',
|
||||
!item.row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={11} className="shrink-0 text-primary/80" />
|
||||
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{item.row.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{item.countLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
if (!canMutatePackages) {
|
||||
return header;
|
||||
}
|
||||
return (
|
||||
<ContextMenu key={item.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{header}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => openRenamePackageDialog(item.row.path)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => requestDeletePackage(item.row.path)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
const snippet = item.kind === 'search' ? item.snippet : item.row.snippet;
|
||||
const isScript = isScriptSnippet(snippet);
|
||||
// Search results drop the package headers, so surface each
|
||||
// snippet's package on the chip itself to keep results
|
||||
// distinguishable (labels are not unique across packages).
|
||||
const chipSubtitle = item.kind === 'search'
|
||||
? (snippet.package || t('terminal.toolbar.library'))
|
||||
: undefined;
|
||||
return (
|
||||
<SnippetChip
|
||||
key={item.key}
|
||||
snippet={snippet}
|
||||
subtitle={chipSubtitle}
|
||||
selected={selectedSnippetIds.has(snippet.id)}
|
||||
multiSelect={isMultiSelectMode}
|
||||
onClick={() => handleSnippetClick(snippet)}
|
||||
onEdit={() => handleEditSnippet(snippet)}
|
||||
onDelete={() => handleDeleteSnippet(snippet.id)}
|
||||
onCopyCommand={() => handleCopySnippetCommand(snippet)}
|
||||
copyCommandLabel={t('scripts.actions.copyCommand')}
|
||||
onRunParallel={onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'parallel')
|
||||
: undefined}
|
||||
onRunSequential={isScript && onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'sequential')
|
||||
: undefined}
|
||||
runParallelLabel={isScript
|
||||
? t('scripts.actions.runParallel')
|
||||
: t('scripts.actions.runOnAllTabs')}
|
||||
runSequentialLabel={t('scripts.actions.runSequential')}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMoreStackedItems ? (
|
||||
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
@@ -1842,117 +1650,5 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
));
|
||||
SnippetRow.displayName = 'SnippetRow';
|
||||
|
||||
interface SnippetChipProps {
|
||||
snippet: Snippet;
|
||||
/** Optional package context shown on the chip (used by stacked search
|
||||
* results, where the package headers are dropped). */
|
||||
subtitle?: string;
|
||||
selected?: boolean;
|
||||
multiSelect?: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onRunParallel?: () => void;
|
||||
onRunSequential?: () => void;
|
||||
onCopyCommand?: () => void;
|
||||
copyCommandLabel?: string;
|
||||
runParallelLabel?: string;
|
||||
runSequentialLabel?: string;
|
||||
editLabel: string;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip for the stacked (wrap) view. Shares the same click → run /
|
||||
* execute and right-click context menu as the list rows; only the layout is
|
||||
* denser. Drag reordering stays list-view-only.
|
||||
*/
|
||||
const SnippetChip = memo<SnippetChipProps>(({
|
||||
snippet,
|
||||
subtitle,
|
||||
selected = false,
|
||||
multiSelect = false,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRunParallel,
|
||||
onRunSequential,
|
||||
onCopyCommand,
|
||||
copyCommandLabel,
|
||||
runParallelLabel,
|
||||
runSequentialLabel,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
|
||||
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={multiSelect ? selected : undefined}
|
||||
className={cn(
|
||||
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
|
||||
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
|
||||
)}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<CheckSquare
|
||||
size={11}
|
||||
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
) : isScriptSnippet(snippet) ? (
|
||||
<Play size={11} className="shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap size={11} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{snippet.label}</span>
|
||||
{subtitle ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
|
||||
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
|
||||
{subtitle}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start">
|
||||
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onRunParallel ? (
|
||||
<ContextMenuItem onClick={onRunParallel}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onRunSequential ? (
|
||||
<ContextMenuItem onClick={onRunSequential}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onCopyCommand ? (
|
||||
<ContextMenuItem onClick={onCopyCommand}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
));
|
||||
SnippetChip.displayName = 'SnippetChip';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
@@ -493,6 +493,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
);
|
||||
const shellHistory = shellHistoryProp ?? (shellHistoryFromStore as ShellHistoryEntry[]);
|
||||
const [rightPanelMode, setRightPanelMode] = useState<RightPanelMode>('none');
|
||||
const [showNewMenu, setShowNewMenu] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>({
|
||||
label: '',
|
||||
command: '',
|
||||
@@ -1664,23 +1665,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
<div className={snippetsHeaderActionsWrapClass}>
|
||||
<div className={cn(snippetsHeaderActionsClass, hasSnippetsSidePanel && 'pr-1')}>
|
||||
<Button onClick={() => handleEdit()} size="sm" className="h-10 px-3 shrink-0">
|
||||
<Plus size={14} className="mr-2" /> {t('snippets.action.newSnippet')}
|
||||
</Button>
|
||||
<Button onClick={() => handleEdit(undefined, true)} size="sm" variant="secondary" className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}>
|
||||
<Play size={14} className="mr-2" /> {t('snippets.action.newScript')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}
|
||||
>
|
||||
<FolderPlus size={14} className="mr-1" /> {t('snippets.action.newPackage')}
|
||||
</Button>
|
||||
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}
|
||||
aria-expanded={showNewMenu}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('snippets.action.new')}
|
||||
<ChevronDown size={14} className={cn('transition-transform', showNewMenu && 'rotate-180')} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-max flex flex-col" align="start" sideOffset={8} keyboardNavigation>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="menuitem"
|
||||
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
|
||||
onClick={() => {
|
||||
setShowNewMenu(false);
|
||||
handleEdit();
|
||||
}}
|
||||
>
|
||||
<Zap size={16} className="text-sky-500" />
|
||||
{t('snippets.action.newSnippet')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="menuitem"
|
||||
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
|
||||
onClick={() => {
|
||||
setShowNewMenu(false);
|
||||
handleEdit(undefined, true);
|
||||
}}
|
||||
>
|
||||
<Play size={16} className="text-violet-500" />
|
||||
{t('snippets.action.newScript')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="menuitem"
|
||||
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
|
||||
onClick={() => {
|
||||
setShowNewMenu(false);
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus size={16} className="text-primary" />
|
||||
{t('snippets.action.newPackage')}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -11,34 +11,10 @@ import {
|
||||
isConvergentConflictSecret,
|
||||
} from '../../domain/convergentSync';
|
||||
import { Button } from '../ui/button';
|
||||
import { Toggle } from '../settings/settings-ui';
|
||||
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
const ConvergentToggle: React.FC<{
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void | Promise<void>;
|
||||
}> = ({ checked, disabled, label, onChange }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label={label}
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 transition-colors ${
|
||||
checked ? 'border-amber-500 bg-amber-500' : 'border-border bg-muted'
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||
checked ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
function conflictLabel(conflict: ConvergentFieldConflict, t: Translate): string {
|
||||
const { address } = conflict;
|
||||
switch (address.kind) {
|
||||
@@ -104,19 +80,19 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
onResolveConflict,
|
||||
onDowngrade,
|
||||
}) => (
|
||||
<div className="space-y-4 overflow-hidden rounded-xl border border-amber-500/25 bg-gradient-to-br from-amber-500/[0.07] via-card to-card p-4 shadow-sm">
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
data-testid="convergent-sync-icon"
|
||||
className="mt-0.5 flex h-9 w-9 shrink-0 self-start items-center justify-center rounded-lg border border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
className="mt-0.5 flex h-9 w-9 shrink-0 self-start items-center justify-center rounded-lg bg-muted text-muted-foreground"
|
||||
>
|
||||
<FlaskConical size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">{t('cloudSync.convergent.title')}</span>
|
||||
<span className="inline-flex rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||
<span className="text-sm font-medium">{t('cloudSync.convergent.title')}</span>
|
||||
<span className="inline-flex rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t('cloudSync.convergent.experimental')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -125,11 +101,11 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConvergentToggle
|
||||
<Toggle
|
||||
checked={config.enabled}
|
||||
onChange={onToggle}
|
||||
disabled={busy}
|
||||
label={t('cloudSync.convergent.title')}
|
||||
ariaLabel={t('cloudSync.convergent.title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -143,20 +119,20 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-3 rounded-lg border border-border/70 bg-background/65 p-3.5 shadow-sm">
|
||||
<div className="space-y-3 rounded-md bg-muted/40 p-3">
|
||||
<div className="text-sm font-semibold">{t('cloudSync.convergent.preview.title')}</div>
|
||||
<div className="grid grid-cols-1 gap-2 text-center sm:grid-cols-3">
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{Object.values(preview.entityCounts).reduce((sum, count) => sum + (count ?? 0), 0)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.entities')}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-lg font-semibold tabular-nums">{preview.providers.length}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.providers')}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-lg font-semibold tabular-nums">{preview.conflictCount}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.conflicts')}</div>
|
||||
</div>
|
||||
@@ -165,7 +141,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
{t('cloudSync.convergent.preview.compatibility')}
|
||||
</div>
|
||||
{preview.providers.length > 0 && (
|
||||
<div className="divide-y rounded border text-xs">
|
||||
<div className="divide-y divide-border/40 text-xs">
|
||||
{preview.providers.map((provider) => (
|
||||
<div key={provider.provider} className="flex items-start justify-between gap-3 px-2 py-1.5">
|
||||
<span className="font-medium">{provider.provider}</span>
|
||||
@@ -178,11 +154,11 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{preview.blockedReasons.length > 0 && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<div className="rounded-md bg-destructive/5 p-2 text-xs text-destructive">
|
||||
{preview.blockedReasons.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={onCancelMigration} disabled={busy}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
@@ -194,7 +170,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<div className="flex gap-2 rounded-md bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
@@ -209,13 +185,13 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
{conflicts.map((conflict) => {
|
||||
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||
return (
|
||||
<div key={addressKey} className="space-y-2 rounded-lg border border-border/70 bg-background/65 p-3 shadow-sm">
|
||||
<div key={addressKey} className="space-y-2 rounded-md bg-muted/40 p-3">
|
||||
<div className="break-all font-mono text-xs">{conflictLabel(conflict, t)}</div>
|
||||
<div className="space-y-1.5">
|
||||
{conflict.candidates.map((candidate) => (
|
||||
<div
|
||||
key={dotKey(candidate.dot)}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/15 p-2.5"
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md bg-background/60 p-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium">
|
||||
@@ -228,7 +204,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={candidate.selected ? 'secondary' : 'outline'}
|
||||
variant={candidate.selected ? 'secondary' : 'ghost'}
|
||||
disabled={busy}
|
||||
onClick={() => onResolveConflict(addressKey, dotKey(candidate.dot))}
|
||||
>
|
||||
@@ -245,7 +221,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
)}
|
||||
|
||||
{config.initialized && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<div className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">{t('cloudSync.convergent.downgrade.desc')}</div>
|
||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={onDowngrade} disabled={busy}>
|
||||
<RotateCcw size={14} className="mr-1" />
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
|
||||
import * as i18n from '../../application/i18n/I18nProvider';
|
||||
import * as editorTabs from '../../application/state/editorTabStore';
|
||||
import * as editorClient from '../../application/state/editorWindowClient';
|
||||
import * as editorRuntime from '../../application/state/useEditorWindow';
|
||||
import * as windowControls from '../../application/state/useWindowControls';
|
||||
import * as editorTabBar from './EditorWindowTabBar';
|
||||
import type { TextEditorPaneProps } from './TextEditorPane';
|
||||
|
||||
// Run the actual page, pane hosts, runtime and store. Only Monaco's visual
|
||||
// surface and the unrelated confirmation/toast chrome are replaced here.
|
||||
// Counting the memoized pane boundary catches content updates leaking through
|
||||
// the page root into every inactive editor, rather than testing a hand-written
|
||||
// approximation of the subscription graph.
|
||||
function loadPage(pane: React.ComponentType<TextEditorPaneProps>) {
|
||||
const source = readFileSync(new URL('./EditorWindowPage.tsx', import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
} }).outputText;
|
||||
const require = createRequire(import.meta.url);
|
||||
const dependencies: Record<string, unknown> = {
|
||||
'../../application/i18n/I18nProvider': i18n,
|
||||
'../../application/state/editorTabStore': editorTabs,
|
||||
'../../application/state/editorWindowClient': editorClient,
|
||||
'../../application/state/useEditorWindow': editorRuntime,
|
||||
'../../application/state/useWindowControls': windowControls,
|
||||
'../ui/toast': { toast: { success() {}, error() {}, warning() {} } },
|
||||
'./UnsavedChangesDialog': {
|
||||
UnsavedChangesProvider: ({ children }: { children: () => React.ReactNode }) => children(),
|
||||
promptUnsavedChanges: async () => 'cancel',
|
||||
},
|
||||
'./EditorWindowTabBar': editorTabBar,
|
||||
'./TextEditorPane': { TextEditorPane: pane },
|
||||
};
|
||||
const module = { exports: {} as { default: React.ComponentType<{ settings: unknown }> } };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(id: string) => dependencies[id] ?? require(id), module, module.exports,
|
||||
);
|
||||
return module.exports.default;
|
||||
}
|
||||
|
||||
const makeTab = (index: number): editorTabs.EditorTab => ({
|
||||
id: `performance-editor-${index}`,
|
||||
kind: 'editor',
|
||||
sessionId: 'fixture-session',
|
||||
sftpTabId: 'fixture-sftp',
|
||||
hostId: 'fixture-host',
|
||||
remotePath: `/file-${index}.txt`,
|
||||
fileName: `file-${index}.txt`,
|
||||
languageId: 'text',
|
||||
content: 'original',
|
||||
baselineContent: 'original',
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
savingState: 'idle',
|
||||
saveError: null,
|
||||
});
|
||||
|
||||
test('detached editor typing and tab switches leave unrelated editor panes untouched', async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const panes = new Map<string, TextEditorPaneProps>();
|
||||
const renders = new Map<string, number>();
|
||||
const titles: string[] = [];
|
||||
const saves: unknown[] = [];
|
||||
const docked: unknown[] = [];
|
||||
let activate: ((payload: { editorId: string }) => void) | undefined;
|
||||
let finishDock: ((result: { success: boolean }) => void) | undefined;
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
setTimeout, clearTimeout, addEventListener() {}, removeEventListener() {},
|
||||
netcatty: {
|
||||
windowIsMaximized: async () => false,
|
||||
setWindowTitle: async (title: string) => { titles.push(title); },
|
||||
onEditorWindowActivateTab: (callback: typeof activate) => { activate = callback; return () => { activate = undefined; }; },
|
||||
saveEditorWindowTab: async (snapshot: unknown) => { saves.push(snapshot); return { ok: true }; },
|
||||
dockEditorWindowTab: (snapshot: unknown) => {
|
||||
docked.push(snapshot);
|
||||
return new Promise<{ success: boolean }>((resolve) => { finishDock = resolve; });
|
||||
},
|
||||
},
|
||||
} });
|
||||
const Pane = React.memo((props: TextEditorPaneProps) => {
|
||||
const name = props.fileName.replace(/ \*$/, '');
|
||||
renders.set(name, (renders.get(name) ?? 0) + 1);
|
||||
panes.set(name, props);
|
||||
return React.createElement('div', { 'data-editor': name });
|
||||
});
|
||||
const Page = loadPage(Pane);
|
||||
const tabs = Array.from({ length: 10 }, (_, index) => makeTab(index));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
for (const tab of tabs) editorTabs.editorTabStore._debugInsert(tab);
|
||||
renderer = create(React.createElement(i18n.I18nProvider, { locale: 'en-US', children: React.createElement(Page, { settings: { hotkeyScheme: 'mac', keyBindings: [] } }) }));
|
||||
});
|
||||
renders.clear();
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await act(async () => panes.get('file-9.txt')!.onContentChange(`updated ${index}`, null));
|
||||
}
|
||||
assert.deepEqual(Object.fromEntries(renders), { 'file-9.txt': 20 });
|
||||
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id)?.content, 'updated 19');
|
||||
assert.equal(titles.at(-1), '* file-9.txt');
|
||||
|
||||
renders.clear();
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await act(async () => activate?.({ editorId: tabs[index].id }));
|
||||
}
|
||||
assert.equal([...renders.values()].reduce((sum, count) => sum + count, 0), 0);
|
||||
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id)?.content, 'updated 19');
|
||||
assert.equal(titles.at(-1), '* file-9.txt');
|
||||
|
||||
await act(async () => { await panes.get('file-9.txt')!.onSave(); });
|
||||
assert.equal(saves.length, 1);
|
||||
assert.equal(editorTabs.editorTabStore.isDirty(tabs[9].id), false);
|
||||
assert.equal(titles.at(-1), 'file-9.txt');
|
||||
|
||||
// Dock completion must not steal focus from a tab selected while the
|
||||
// acknowledgement was pending; stable callbacks read the latest selection.
|
||||
await act(async () => { void panes.get('file-9.txt')!.onDockToMain?.(); });
|
||||
assert.equal(docked.length, 1);
|
||||
await act(async () => activate?.({ editorId: tabs[0].id }));
|
||||
await act(async () => finishDock?.({ success: true }));
|
||||
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id), undefined);
|
||||
assert.equal(titles.at(-1), 'file-0.txt');
|
||||
|
||||
await act(async () => panes.get('file-0.txt')!.onRequestClose?.());
|
||||
assert.equal(editorTabs.editorTabStore.getTab(tabs[0].id), undefined);
|
||||
assert.equal(titles.at(-1), 'file-8.txt');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
for (const tab of tabs) editorTabs.editorTabStore.close(tab.id);
|
||||
});
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
}
|
||||
});
|
||||
@@ -84,7 +84,7 @@ function EditorWindowControls({ onClose }: { onClose: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EditorWindowPaneHost({
|
||||
const EditorWindowPaneHost = React.memo(function EditorWindowPaneHost({
|
||||
tabId,
|
||||
isVisible,
|
||||
settings,
|
||||
@@ -130,6 +130,16 @@ function EditorWindowPaneHost({
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
const handleLanguageChange = useCallback((language: string) => {
|
||||
editorTabStore.setLanguage(tabId, language);
|
||||
}, [tabId]);
|
||||
const handleToggleWordWrap = useCallback(() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}, [tabId]);
|
||||
const handleRequestClose = useCallback(() => onRequestClose(tabId), [onRequestClose, tabId]);
|
||||
const handleDock = useCallback(() => onDock(tabId), [onDock, tabId]);
|
||||
|
||||
if (!tab) return null;
|
||||
const isDirty = tabIsDirty(tab);
|
||||
const hostLabel = tab.hostLabel || tab.hostId;
|
||||
@@ -151,18 +161,35 @@ function EditorWindowPaneHost({
|
||||
hotkeyScheme={settings.hotkeyScheme}
|
||||
keyBindings={settings.keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={(lang) => editorTabStore.setLanguage(tabId, lang)}
|
||||
onToggleWordWrap={() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}}
|
||||
onSave={() => { void handleSave(); }}
|
||||
onRequestClose={() => onRequestClose(tabId)}
|
||||
onDockToMain={() => onDock(tabId)}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
onRequestClose={handleRequestClose}
|
||||
onDockToMain={handleDock}
|
||||
initialViewState={tab.viewState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Keep content/dirty notifications out of the page and inactive editor panes. */
|
||||
function EditorWindowTitle({
|
||||
activeTabId,
|
||||
setWindowTitle,
|
||||
}: {
|
||||
activeTabId: EditorTabId | null;
|
||||
setWindowTitle: (title: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const activeTab = useEditorTab(activeTabId ?? "");
|
||||
const activeDirty = useEditorTabDirty(activeTabId ?? "");
|
||||
useEffect(() => {
|
||||
const name = activeTab?.fileName;
|
||||
void setWindowTitle(name
|
||||
? `${activeDirty ? "* " : ""}${name}`
|
||||
: t("sftp.editor.windowTitle"));
|
||||
}, [activeDirty, activeTab?.fileName, setWindowTitle, t]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
@@ -171,23 +198,12 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
const { onWindowCommandCloseRequested } = useWindowControls();
|
||||
const tabs = useEditorTabChromeList();
|
||||
const closeInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const activeTab = useEditorTab(activeTabId ?? "");
|
||||
const activeDirty = useEditorTabDirty(activeTabId ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabId && editorTabStore.getTab(activeTabId)) return;
|
||||
if (tabs.length > 0) setActiveTabId(tabs[tabs.length - 1].id);
|
||||
}, [activeTabId, setActiveTabId, tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const name = activeTab?.fileName;
|
||||
if (!name) {
|
||||
void setWindowTitle(t("sftp.editor.windowTitle"));
|
||||
return;
|
||||
}
|
||||
void setWindowTitle(`${activeDirty ? "* " : ""}${name}`);
|
||||
}, [activeDirty, activeTab?.fileName, setWindowTitle, t]);
|
||||
|
||||
const saveTab = useCallback(async (tabId: EditorTabId): Promise<boolean> => {
|
||||
const result = await saveDetachedEditorTab(tabId);
|
||||
if (result.ok) return true;
|
||||
@@ -221,16 +237,16 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
await closeWindow();
|
||||
return true;
|
||||
}
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
|
||||
}
|
||||
setActiveTabId((current) => current === tabId
|
||||
? remaining[remaining.length - 1]?.id ?? null
|
||||
: current);
|
||||
return true;
|
||||
})().finally(() => {
|
||||
closeInFlightRef.current = null;
|
||||
});
|
||||
closeInFlightRef.current = task;
|
||||
return task;
|
||||
}, [activeTabId, closeTab, closeWindow, setActiveTabId]);
|
||||
}, [closeTab, closeWindow, setActiveTabId]);
|
||||
|
||||
const handleDock = useCallback(async (tabId: EditorTabId) => {
|
||||
const result = await dockDetachedEditorTab(tabId);
|
||||
@@ -244,10 +260,14 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
await closeWindow();
|
||||
return;
|
||||
}
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
|
||||
}
|
||||
}, [activeTabId, closeWindow, setActiveTabId, t]);
|
||||
setActiveTabId((current) => current === tabId
|
||||
? remaining[remaining.length - 1]?.id ?? null
|
||||
: current);
|
||||
}, [closeWindow, setActiveTabId, t]);
|
||||
|
||||
const handleRequestClose = useCallback((tabId: EditorTabId) => {
|
||||
void closeTabAndMaybeWindow(tabId);
|
||||
}, [closeTabAndMaybeWindow]);
|
||||
|
||||
const handleWindowClose = useCallback(async () => {
|
||||
// Native close owns confirmation and blocks new transfers until it settles.
|
||||
@@ -296,6 +316,7 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<EditorWindowTitle activeTabId={activeTabId} setWindowTitle={setWindowTitle} />
|
||||
<div className="flex h-10 shrink-0 items-center border-b border-border/60 app-drag">
|
||||
{isMac ? <div className="w-[72px] shrink-0" /> : null}
|
||||
<div className="px-3 text-xs font-semibold text-muted-foreground app-no-drag">
|
||||
@@ -321,8 +342,8 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
tabId={tab.id}
|
||||
isVisible={tab.id === activeTabId}
|
||||
settings={settings}
|
||||
onRequestClose={(id) => { void closeTabAndMaybeWindow(id); }}
|
||||
onDock={(id) => { void handleDock(id); }}
|
||||
onRequestClose={handleRequestClose}
|
||||
onDock={handleDock}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useId, useState } from 'react';
|
||||
import { Check, Globe, Loader2, Monitor } from 'lucide-react';
|
||||
import type { BrowserBookmark } from '../../domain/browserBookmark';
|
||||
import type { HostBrowserBookmark } from '../../domain/hostBrowser';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { AsidePanelContent, AsidePanelFooter } from '../ui/aside-panel';
|
||||
import { BrowserIcon } from './BrowserIcon';
|
||||
import { BrowserIconEditor, type BrowserIconSelection } from './BrowserIconEditor';
|
||||
|
||||
export function BrowserBookmarkEditor({ initial, onSave, onCancel, readIcon }: {
|
||||
initial: Partial<HostBrowserBookmark>;
|
||||
onSave: (bookmark: Partial<BrowserBookmark>) => Promise<boolean>;
|
||||
onCancel: () => void;
|
||||
readIcon: (file: File) => Promise<string | null>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const id = useId();
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [icon, setIcon] = useState<BrowserIconSelection>({
|
||||
mode: initial.iconDataUrl ? 'image' : initial.iconMode === 'custom' ? 'text' : 'website',
|
||||
glyph: initial.icon || '', color: initial.color || '#247969', image: initial.iconDataUrl || null,
|
||||
});
|
||||
const [reading, setReading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const invalidIcon = reading || (icon.mode === 'image' && !icon.image);
|
||||
const previewImage = icon.mode === 'image' ? icon.image : icon.mode === 'website' ? initial.siteIconDataUrl : null;
|
||||
const previewGlyph = icon.mode === 'text' ? icon.glyph || draft.title?.trim().slice(0, 1) : icon.mode === 'website' ? initial.siteIconGlyph : null;
|
||||
let addressHint = t('terminal.browser.bookmarkPreviewHint');
|
||||
try { if (draft.url) addressHint = new URL(draft.url).host; } catch { /* Keep the hint while an address is being typed. */ }
|
||||
|
||||
return <form className="flex min-h-0 flex-1 flex-col" data-section="browser-bookmark-editor" onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (invalidIcon || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await onSave({ ...draft, title: draft.title?.trim(), url: draft.url?.trim(),
|
||||
color: icon.color, iconMode: icon.mode === 'website' ? 'auto' : 'custom',
|
||||
iconDataUrl: icon.mode === 'image' ? icon.image : null, icon: icon.mode === 'text' ? icon.glyph : '' });
|
||||
if (saved) onCancel();
|
||||
} finally { setSaving(false); }
|
||||
}}>
|
||||
<AsidePanelContent className="min-h-0">
|
||||
<div className="flex min-w-0 items-center gap-3 border-b border-border/40 pb-4" data-section="bookmark-preview">
|
||||
<BrowserIcon className="h-12 w-12 rounded-xl border border-border/50 bg-muted/40 p-2.5 [&>span]:text-lg [&>svg]:h-5 [&>svg]:w-5" image={previewImage} glyph={previewGlyph} color={icon.mode === 'website' ? initial.siteIconColor : icon.color} />
|
||||
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{draft.title?.trim() || t('terminal.browser.untitledBookmark')}</p><p className="mt-1 truncate text-xs text-muted-foreground">{addressHint}</p></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-title`} className="text-xs text-muted-foreground">{t('terminal.browser.bookmarkTitle')}</Label>
|
||||
<Input id={`${id}-title`} autoFocus required maxLength={120} className="h-9" placeholder={t('terminal.browser.bookmarkTitlePlaceholder')} value={draft.title || ''} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-url`} className="text-xs text-muted-foreground">{t('terminal.browser.websiteAddress')}</Label>
|
||||
<Input id={`${id}-url`} required type="url" className="h-9" placeholder="https://example.com" spellCheck={false} autoComplete="off" value={draft.url || ''} onChange={(event) => setDraft((current) => ({ ...current, url: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t('terminal.browser.icon')}</p>
|
||||
<BrowserIconEditor value={icon} onChange={(changes) => setIcon((current) => ({ ...current, ...changes }))} readIcon={readIcon} onBusyChange={setReading} />
|
||||
</div>
|
||||
<fieldset className="min-w-0 space-y-2.5">
|
||||
<legend className="mb-2.5 text-xs font-medium text-muted-foreground">{t('terminal.browser.scope')}</legend>
|
||||
{(['host', 'all-hosts'] as const).map((scope) => {
|
||||
const selected = (draft.scope || 'host') === scope;
|
||||
const Icon = scope === 'host' ? Monitor : Globe;
|
||||
return <label key={scope} className={cn('relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors focus-within:ring-1 focus-within:ring-ring', selected ? 'border-primary/35 bg-primary/5' : 'border-border/50 hover:bg-muted/30')}>
|
||||
<input type="radio" name={`${id}-scope`} value={scope} checked={selected} onChange={() => setDraft((current) => ({ ...current, scope }))} className="sr-only" />
|
||||
<Icon size={15} className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground')} />
|
||||
<span className="min-w-0 flex-1"><span className="block text-xs font-medium">{t(scope === 'host' ? 'terminal.browser.hostScope' : 'terminal.browser.allScope')}</span><span className="mt-1 block text-[11px] leading-relaxed text-muted-foreground">{t(scope === 'host' ? 'terminal.browser.hostScopeHint' : 'terminal.browser.allScopeHint')}</span></span>
|
||||
<span className={cn('grid h-4 w-4 shrink-0 place-items-center rounded-full border', selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40')}>{selected && <Check size={10} />}</span>
|
||||
</label>;
|
||||
})}
|
||||
</fieldset>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" size="sm" className="min-w-24" disabled={saving || invalidIcon}>{saving && <Loader2 size={13} className="mr-1.5 animate-spin" />}{t(initial.id ? 'common.save' : 'terminal.browser.addBookmark')}</Button>
|
||||
</AsidePanelFooter>
|
||||
</form>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { File, FolderOpen, MoreHorizontal, X } from 'lucide-react';
|
||||
import type { HostBrowserDownload } from '../../domain/hostBrowser';
|
||||
import type { useHostBrowserControls } from '../../application/state/useHostBrowserControls';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
function size(bytes: number) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = bytes > 0 ? Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024))) : 0;
|
||||
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function BrowserDownloads({ items, command }: { items: HostBrowserDownload[]; command: ReturnType<typeof useHostBrowserControls>['downloadCommand'] }) {
|
||||
const { t } = useI18n();
|
||||
return <div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2"><div className="min-w-0"><p className="text-xs font-medium">{t('terminal.browser.downloads')}</p><p className="mt-1 text-[11px] text-muted-foreground">{t('terminal.browser.downloadLocation')}</p></div><Button size="icon" variant="ghost" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.openFolder')} title={t('terminal.browser.openFolder')} onClick={() => void command('open-folder')}><FolderOpen size={14} /></Button></div>
|
||||
<div className="max-h-80 overflow-y-auto p-1">
|
||||
{!items.length && <p className="px-3 py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.downloadEmpty')}</p>}
|
||||
{items.map((item) => {
|
||||
const running = item.status === 'waiting' || item.status === 'progressing';
|
||||
const canOpen = item.status === 'completed' && !item.missing;
|
||||
return <div key={item.id} data-download-id={item.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/50">
|
||||
<File size={16} className="shrink-0 text-muted-foreground" /><div className="min-w-0 flex-1"><p className="truncate text-xs" title={item.filename}>{item.filename}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{t(`terminal.browser.${item.missing ? 'missing' : item.status}`)}{item.status === 'progressing' ? ` · ${size(item.received)}${item.total ? ` / ${size(item.total)}` : ''}` : item.status === 'completed' ? ` · ${size(item.total || item.received)}` : ''}</p>
|
||||
{item.status === 'progressing' && <div className="mt-1 h-1 overflow-hidden rounded bg-secondary"><div className={`h-full bg-primary${item.total ? '' : ' animate-pulse'}`} style={{ width: `${item.total ? Math.min(100, item.received * 100 / item.total) : 20}%` }} /></div>}</div>
|
||||
{running ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.cancelDownload')} title={t('terminal.browser.cancelDownload')} onClick={() => void command('cancel', item.id)}><X size={13} /></Button>
|
||||
: canOpen ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" title={t('terminal.browser.reveal')} onClick={() => void command('reveal', item.id)}><FolderOpen size={13} /></Button> : null}
|
||||
<Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.more')}><MoreHorizontal size={13} /></Button></PopoverTrigger><PopoverContent align="end" className="w-40 p-1">
|
||||
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('open', item.id)}>{t('terminal.browser.openFile')}</Button>}
|
||||
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('save-as', item.id)}>{t('terminal.browser.saveAs')}</Button>}
|
||||
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('reveal', item.id)}>{t('terminal.browser.reveal')}</Button>}
|
||||
{!running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('remove', item.id)}>{t('terminal.browser.removeDownload')}</Button>}
|
||||
{running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('cancel', item.id)}>{t('terminal.browser.cancelDownload')}</Button>}
|
||||
</PopoverContent></Popover>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
{items.some((item) => !['waiting', 'progressing'].includes(item.status)) && <div className="border-t border-border/50 p-1"><Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => void command('clear')}>{t('terminal.browser.clearDownloads')}</Button></div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
export function BrowserIcon({ image, glyph, color, className = '' }: {
|
||||
image?: string | null; glyph?: string | null; color?: string | null; className?: string;
|
||||
}) {
|
||||
return <span className={`inline-flex shrink-0 items-center justify-center overflow-hidden ${className}`}>
|
||||
{image ? <img src={image} alt="" className="h-full w-full rounded-[20%] object-contain" draggable={false} />
|
||||
: glyph ? <span className="truncate text-xs" style={{ color: color || undefined }}>{glyph}</span>
|
||||
: <Globe size={14} className="text-muted-foreground" />}
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Check, Globe, ImagePlus, Loader2, Pipette, Type, Upload, X } from 'lucide-react';
|
||||
import { HostBrowserIconError } from '../../domain/hostBrowser';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
|
||||
export interface BrowserIconSelection {
|
||||
mode: 'website' | 'text' | 'image';
|
||||
glyph: string;
|
||||
color: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
const COLORS = ['#247969', '#2563eb', '#7c3aed', '#c026d3', '#e11d48', '#ea580c', '#ca8a04', '#64748b'];
|
||||
const MODES = [{ value: 'website', Icon: Globe }, { value: 'text', Icon: Type }, { value: 'image', Icon: ImagePlus }] as const;
|
||||
|
||||
export function BrowserIconEditor({ value, onChange, readIcon, onBusyChange, allowWebsite = true }: {
|
||||
value: BrowserIconSelection;
|
||||
onChange: (changes: Partial<BrowserIconSelection>) => void;
|
||||
readIcon: (file: File) => Promise<string | null>;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
allowWebsite?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const inputId = useId();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const readRevision = useRef(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [filename, setFilename] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => () => { readRevision.current++; }, []);
|
||||
|
||||
const selectFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
const revision = ++readRevision.current;
|
||||
setError(null); setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const image = await readIcon(file);
|
||||
if (revision !== readRevision.current) return;
|
||||
if (!image) throw new HostBrowserIconError('decode');
|
||||
onChange({ image }); setFilename(file.name);
|
||||
} catch (reason) {
|
||||
if (revision === readRevision.current) {
|
||||
const key = reason instanceof HostBrowserIconError ? reason.reason : 'decode';
|
||||
setError(t(`terminal.browser.iconError.${key}`));
|
||||
}
|
||||
} finally {
|
||||
if (revision === readRevision.current) { setBusy(false); onBusyChange(false); }
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="space-y-3" data-section="browser-icon-editor">
|
||||
<div className="grid grid-flow-col auto-cols-fr gap-1 rounded-md bg-muted/50 p-1" role="group" aria-label={t('terminal.browser.icon')}>
|
||||
{MODES.filter((mode) => allowWebsite || mode.value !== 'website').map(({ value: mode, Icon }) => <button
|
||||
key={mode} type="button" aria-pressed={value.mode === mode} disabled={busy}
|
||||
className={cn('flex min-w-0 items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50',
|
||||
value.mode === mode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => { onChange({ mode }); setError(null); }}>
|
||||
<Icon size={13} className="shrink-0" /><span className="truncate">{t(`terminal.browser.${mode}`)}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
{value.mode === 'website' && <p className="text-xs leading-relaxed text-muted-foreground">{t('terminal.browser.websiteIconHint')}</p>}
|
||||
{value.mode === 'text' && <div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input id={inputId} aria-label={t('terminal.browser.iconText')} className="h-9 w-20 shrink-0 text-center text-base" maxLength={4} value={value.glyph}
|
||||
onChange={(event) => onChange({ glyph: event.target.value })} placeholder="A" />
|
||||
<Label htmlFor={inputId} className="text-xs font-normal leading-relaxed text-muted-foreground">{t('terminal.browser.iconTextHint')}</Label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2" role="group" aria-label={t('terminal.browser.color')}>
|
||||
{COLORS.map((color) => <button key={color} type="button" aria-label={t('terminal.browser.selectColor', { color })} aria-pressed={value.color.toLowerCase() === color}
|
||||
className={cn('grid h-6 w-6 place-items-center rounded-full border-2 border-background transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', value.color.toLowerCase() === color && 'ring-1 ring-foreground/40')}
|
||||
style={{ backgroundColor: color }} onClick={() => onChange({ color })}>
|
||||
{value.color.toLowerCase() === color && <Check size={12} className="text-white" />}
|
||||
</button>)}
|
||||
<label className="relative grid h-6 w-6 cursor-pointer place-items-center rounded-full border border-border text-muted-foreground hover:text-foreground focus-within:ring-1 focus-within:ring-ring" title={t('terminal.browser.customColor')}>
|
||||
<Pipette size={12} /><input aria-label={t('terminal.browser.customColor')} type="color" className="absolute inset-0 h-full w-full cursor-pointer opacity-0" value={value.color} onChange={(event) => onChange({ color: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>}
|
||||
{value.mode === 'image' && <div className="space-y-2" onDragOver={(event) => { event.preventDefault(); setDragging(true); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
|
||||
onDrop={(event) => { event.preventDefault(); setDragging(false); if (!busy) void selectFile(event.dataTransfer.files[0]); }}>
|
||||
<input ref={fileInput} type="file" className="hidden" aria-label={t('terminal.browser.chooseImage')}
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon,image/vnd.microsoft.icon"
|
||||
onChange={(event) => { void selectFile(event.target.files?.[0]); event.target.value = ''; }} />
|
||||
{value.image ? <div className={cn('flex min-w-0 items-center gap-3 rounded-lg border border-border/60 bg-muted/20 p-3', dragging && 'border-primary bg-primary/5')}>
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-md border border-border/40 bg-background p-1.5"><img src={value.image} alt={t('terminal.browser.iconPreview')} className="h-full w-full rounded-[20%] object-contain" /></div>
|
||||
<div className="min-w-0 flex-1"><p className="truncate text-xs font-medium" title={filename}>{filename || t('terminal.browser.savedImage')}</p><Button type="button" variant="link" size="sm" className="h-auto px-0 py-1 text-xs" disabled={busy} onClick={() => fileInput.current?.click()}>{busy ? t('terminal.browser.processingImage') : t('terminal.browser.replaceImage')}</Button></div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground" disabled={busy} aria-label={t('terminal.browser.removeImage')} onClick={() => { onChange({ image: null }); setFilename(''); setError(null); }}><X size={14} /></Button>
|
||||
</div> : <button type="button" disabled={busy} className={cn('flex w-full flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/15 px-4 py-5 text-center transition-colors hover:border-primary/50 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', dragging && 'border-primary bg-primary/5')}
|
||||
onClick={() => fileInput.current?.click()}>
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-secondary/70 text-muted-foreground">{busy ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}</span>
|
||||
<span className="text-xs font-medium">{t(busy ? 'terminal.browser.processingImage' : 'terminal.browser.uploadImage')}</span>
|
||||
<span className="text-[11px] leading-relaxed text-muted-foreground">{t('terminal.browser.dropImage')}</span>
|
||||
</button>}
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">{t('terminal.browser.imageHint')}</p>
|
||||
</div>}
|
||||
{error && <p role="alert" className="text-xs leading-relaxed text-destructive">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -121,7 +121,11 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
const update = () => {
|
||||
const height = container.clientHeight;
|
||||
setViewportHeight(height);
|
||||
if (height > 0) setScrollTop(container.scrollTop);
|
||||
};
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { useLayoutEffect } from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useSftpPaneVirtualList } from './hooks/useSftpPaneVirtualList';
|
||||
import { useSftpPaneTreeRows } from './useSftpPaneTreeRows';
|
||||
import type { SftpFileEntry } from '../../types';
|
||||
import { installDomEnvironment } from '../test-support/renderReactDom';
|
||||
|
||||
const files: SftpFileEntry[] = Array.from({ length: 5000 }, (_, i) => ({
|
||||
name: `file-${i}.txt`, type: 'file', size: 1, lastModified: 0,
|
||||
sizeFormatted: '1 B', lastModifiedFormatted: '--',
|
||||
}));
|
||||
|
||||
test('file windows stay bounded on the first commit, hidden tabs and measured scrolling', async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
const previousObserver = globalThis.ResizeObserver;
|
||||
let resize = () => {};
|
||||
globalThis.ResizeObserver = class implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { resize = () => callback([], this); }
|
||||
observe() {} unobserve() {} disconnect() {}
|
||||
};
|
||||
let nextFrame = 0;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
dom.window.requestAnimationFrame = callback => { frames.set(++nextFrame, callback); return nextFrame; };
|
||||
dom.window.cancelAnimationFrame = id => { frames.delete(id); };
|
||||
const flushFrames = async () => {
|
||||
const batch = [...frames.values()]; frames.clear();
|
||||
await act(async () => { batch.forEach(callback => callback(0)); });
|
||||
};
|
||||
const node = { clientHeight: 600, scrollTop: 0, querySelector: () => ({ getBoundingClientRect: () => ({ height: measuredHeight }) }) };
|
||||
let measuredHeight = 44;
|
||||
let result!: ReturnType<typeof useSftpPaneVirtualList>;
|
||||
const commits: number[] = [];
|
||||
function Fixture({ active, entries = files, density = 'comfortable' }: { active: boolean; entries?: SftpFileEntry[]; density?: string }) {
|
||||
result = useSftpPaneVirtualList({ isActive: active, sortedDisplayFiles: entries, layoutKey: density });
|
||||
useLayoutEffect(() => { commits.push(result.visibleRows.length); });
|
||||
return React.createElement('div', { ref: result.fileListRef });
|
||||
}
|
||||
let renderer!: ReactTestRenderer;
|
||||
t.after(async () => {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
globalThis.ResizeObserver = previousObserver;
|
||||
dom.cleanup();
|
||||
});
|
||||
await act(async () => { renderer = create(React.createElement(Fixture, { active: true }), { createNodeMock: () => node }); });
|
||||
assert.ok(commits.every(count => count < 100), `initial commits must be bounded: ${commits}`);
|
||||
await flushFrames();
|
||||
node.scrollTop = 4999 * result.rowHeight;
|
||||
await act(async () => { result.handleFileListScroll({ currentTarget: node } as unknown as React.UIEvent<HTMLDivElement>); });
|
||||
await flushFrames();
|
||||
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1), 'the final file remains reachable');
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: false })); });
|
||||
assert.ok(result.shouldVirtualize && result.visibleRows.length < 100, 'hiding must retain the file window');
|
||||
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1));
|
||||
node.clientHeight = 0;
|
||||
await act(async () => { resize(); });
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: true })); });
|
||||
assert.ok(result.visibleRows.length < 100, 'a zero-size viewport must not mount every file');
|
||||
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1), 'restoring retains the last-file window');
|
||||
const fewerFiles = files.slice(0, 100);
|
||||
node.clientHeight = 600;
|
||||
node.scrollTop = fewerFiles.length * result.rowHeight - node.clientHeight;
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: false, entries: fewerFiles })); });
|
||||
assert.ok(result.visibleRows.length > 0, 'an inactive listing shrink must not leave an empty window');
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, entries: fewerFiles })); });
|
||||
assert.equal(result.visibleRows.at(-1)?.entry, fewerFiles.at(-1));
|
||||
assert.ok(result.visibleRows[0].top <= node.scrollTop, 'restoring reads the browser-clamped scroll position');
|
||||
assert.ok(result.visibleRows.at(-1)!.top + result.rowHeight >= node.scrollTop + node.clientHeight);
|
||||
measuredHeight = 24;
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, density: 'compact' })); resize(); });
|
||||
await flushFrames();
|
||||
assert.equal(result.rowHeight, 24, 'density is still measured from real rows');
|
||||
node.scrollTop = 0;
|
||||
await act(async () => { result.handleFileListScroll({ currentTarget: node } as unknown as React.UIEvent<HTMLDivElement>); });
|
||||
await flushFrames();
|
||||
assert.equal(result.visibleRows[0]?.entry, files[0]);
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, entries: files.slice(0, 10) })); });
|
||||
assert.equal(result.shouldVirtualize, false);
|
||||
assert.equal(result.visibleRows.length, 10);
|
||||
});
|
||||
|
||||
test('tree windows stay bounded before measurement and when the viewport becomes hidden', async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
let result!: ReturnType<typeof useSftpPaneTreeRows>;
|
||||
const descriptors = files.map(entry => ({ type: 'node', entry, entryPath: `/fixture/${entry.name}`, depth: 0 }));
|
||||
function Fixture({ height, top = 0, count = files.length }: { height: number; top?: number; count?: number }) {
|
||||
result = useSftpPaneTreeRows({ nodeDescriptors: descriptors.slice(0, count), viewportHeight: height, scrollTop: top, selectedPaths: new Set(), tRef: { current: (key: string) => key } });
|
||||
return null;
|
||||
}
|
||||
let renderer!: ReactTestRenderer;
|
||||
t.after(async () => { await act(async () => { renderer?.unmount(); }); dom.cleanup(); });
|
||||
await act(async () => { renderer = create(React.createElement(Fixture, { height: 0 })); });
|
||||
assert.ok(result.visibleRange.virtualized && result.treeRows.length < 100, 'initial tree commit must be bounded');
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { height: 600, top: 4999 * 28 })); });
|
||||
assert.equal(result.visibleRange.end, 4999);
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { height: 0, top: 4999 * 28 })); });
|
||||
assert.ok(result.treeRows.length < 100, 'zero height must not mount every tree row');
|
||||
assert.equal(result.visibleRange.end, 4999);
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { height: 0, top: 4999 * 28, count: 100 })); });
|
||||
assert.ok(result.treeRows.length > 0, 'a hidden tree refresh must retain rows after the listing shrinks');
|
||||
assert.equal(result.visibleRange.end, 99);
|
||||
await act(async () => { renderer.update(React.createElement(Fixture, { height: 600, top: 4999 * 28, count: 100 })); });
|
||||
assert.ok(result.treeRows.length > 0);
|
||||
assert.equal(result.visibleRange.end, 99);
|
||||
});
|
||||
@@ -24,7 +24,8 @@ export const useSftpPaneVirtualList = ({
|
||||
layoutKey,
|
||||
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
const [rowHeight, setRowHeight] = useState(0);
|
||||
// Bound the first commit too; the mounted rows refine this estimate below.
|
||||
const [rowHeight, setRowHeight] = useState(44);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
@@ -32,7 +33,14 @@ export const useSftpPaneVirtualList = ({
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !isActive || !enabled) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
const update = () => {
|
||||
const height = container.clientHeight;
|
||||
setViewportHeight(height);
|
||||
// Scroll events are ignored while parked; the browser may still clamp
|
||||
// its position when a refreshed directory becomes shorter. Hidden
|
||||
// elements report zero geometry, so preserve their last visible offset.
|
||||
if (height > 0) setScrollTop(container.scrollTop);
|
||||
};
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
@@ -82,18 +90,23 @@ export const useSftpPaneVirtualList = ({
|
||||
|
||||
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
|
||||
const overscan = 6;
|
||||
const canVirtualize = enabled && isActive && viewportHeight > 0 && rowHeight > 0;
|
||||
// Hidden/unmeasured panes still need a window, rather than all file rows.
|
||||
const canVirtualize = enabled && rowHeight > 0;
|
||||
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
|
||||
const totalHeightLocal = shouldVirtualizeLocal
|
||||
? sortedDisplayFiles.length * rowHeight
|
||||
: 0;
|
||||
const windowTop = Math.max(0, Math.min(
|
||||
scrollTop,
|
||||
totalHeightLocal - viewportHeight,
|
||||
));
|
||||
const startIndex = shouldVirtualizeLocal
|
||||
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
|
||||
? Math.max(0, Math.floor(windowTop / rowHeight) - overscan)
|
||||
: 0;
|
||||
const endIndex = shouldVirtualizeLocal
|
||||
? Math.min(
|
||||
sortedDisplayFiles.length - 1,
|
||||
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
|
||||
Math.ceil((windowTop + viewportHeight) / rowHeight) + overscan,
|
||||
)
|
||||
: sortedDisplayFiles.length - 1;
|
||||
const visibleRowsLocal = shouldVirtualizeLocal
|
||||
@@ -115,7 +128,7 @@ export const useSftpPaneVirtualList = ({
|
||||
totalHeight: totalHeightLocal,
|
||||
visibleRows: visibleRowsLocal,
|
||||
};
|
||||
}, [enabled, isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
|
||||
}, [enabled, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
|
||||
|
||||
return {
|
||||
fileListRef,
|
||||
|
||||
@@ -15,15 +15,17 @@ export function useSftpPaneTreeRows(props: SftpPaneTreeRowsProps) {
|
||||
const { totalHeight, visibleRange } = useMemo(() => {
|
||||
const totalCount = nodeDescriptors.length;
|
||||
const total = totalCount * TREE_ROW_HEIGHT;
|
||||
const shouldVirtualize = viewportHeight > 0 && totalCount > 50;
|
||||
// A zero viewport also occurs on first mount and while parked/hidden.
|
||||
const shouldVirtualize = totalCount > 50;
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
return { totalHeight: 0, visibleRange: { start: 0, end: totalCount - 1, virtualized: false } };
|
||||
}
|
||||
|
||||
const overscan = 6;
|
||||
const start = Math.max(0, Math.floor(scrollTop / TREE_ROW_HEIGHT) - overscan);
|
||||
const end = Math.min(totalCount - 1, Math.ceil((scrollTop + viewportHeight) / TREE_ROW_HEIGHT) + overscan);
|
||||
const windowTop = Math.max(0, Math.min(scrollTop, total - viewportHeight));
|
||||
const start = Math.max(0, Math.floor(windowTop / TREE_ROW_HEIGHT) - overscan);
|
||||
const end = Math.min(totalCount - 1, Math.ceil((windowTop + viewportHeight) / TREE_ROW_HEIGHT) + overscan);
|
||||
return { totalHeight: total, visibleRange: { start, end, virtualized: true } };
|
||||
}, [nodeDescriptors.length, scrollTop, viewportHeight]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { getCompletions } from "./autocomplete/completionEngine";
|
||||
import { DEFAULT_AUTOCOMPLETE_SETTINGS } from "./autocomplete/useTerminalAutocomplete";
|
||||
import { getPinyinCacheStatsForTests, resetPinyinCacheForTests } from "../../lib/searchMatcher";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
const deploySnippet: Snippet = { id: "d", label: "deploy", command: "kubectl apply -f ." };
|
||||
@@ -83,3 +84,34 @@ test("getCompletions returns more than 8 snippet matches when default maxSuggest
|
||||
);
|
||||
assert.equal(snippetMatches.length, 20);
|
||||
});
|
||||
|
||||
|
||||
test("argument completion skips ASCII transliteration in a large snippet library", async () => {
|
||||
const snippets: Snippet[] = Array.from({ length: 5000 }, (_, index) => ({
|
||||
id: `snippet-${index}`,
|
||||
label: `deploy-service-${index}`,
|
||||
command: `kubectl --namespace production rollout restart deployment service-${index}`,
|
||||
}));
|
||||
snippets.push({ id: "mail", label: "邮箱", command: "user@example.com" });
|
||||
resetPinyinCacheForTests();
|
||||
try {
|
||||
for (const input of ["git pull zz", "git pull zzq", "git pull zzqx"]) {
|
||||
assert.deepEqual(await getCompletions(input, { snippets, skipPathCompletion: true }), []);
|
||||
}
|
||||
assert.deepEqual(
|
||||
getPinyinCacheStatsForTests().keys,
|
||||
["邮箱"],
|
||||
"ASCII command tokens cannot produce a new pinyin prefix and must not enter its cache",
|
||||
);
|
||||
const result = await getCompletions("git pull yx", { snippets, skipPathCompletion: true });
|
||||
assert.equal(result.find((suggestion) => suggestion.source === "snippet")?.snippet?.id, "mail");
|
||||
} finally {
|
||||
resetPinyinCacheForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("ASCII argument completion retains compact query prefixes", async () => {
|
||||
const snippets: Snippet[] = [{ id: "compact", label: "production-foobar", command: "echo ready" }];
|
||||
const result = await getCompletions("git pull foo_bar", { snippets, skipPathCompletion: true });
|
||||
assert.equal(result.find((suggestion) => suggestion.source === "snippet")?.snippet?.id, "compact");
|
||||
});
|
||||
@@ -145,10 +145,16 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
|
||||
};
|
||||
|
||||
// Content Component (wraps children with scroll)
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string; scrollMode?: "custom" | "native" }> = ({
|
||||
children,
|
||||
className,
|
||||
scrollMode = "custom",
|
||||
}) => {
|
||||
if (scrollMode === "native") return (
|
||||
<div data-aside-scroll-viewport className={cn("flex-1 min-h-0 min-w-0 overflow-y-auto overscroll-y-contain host-tree-notes-scroll", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
|
||||
|
||||
@@ -123,6 +123,8 @@ interface DropdownContentProps {
|
||||
alignToParent?: boolean;
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
|
||||
/** Opt in for action menus whose options need keyboard focus and navigation. */
|
||||
keyboardNavigation?: boolean;
|
||||
}
|
||||
|
||||
const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
@@ -134,9 +136,11 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
alignToParent = false,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
keyboardNavigation = false,
|
||||
}) => {
|
||||
const { open, setOpen, triggerRef } = useDropdown();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const didFocusOnOpen = useRef(false);
|
||||
const [position, setPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
@@ -248,6 +252,17 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
}
|
||||
}, [open, calculatePosition]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
didFocusOnOpen.current = false;
|
||||
return;
|
||||
}
|
||||
if (keyboardNavigation && position && !didFocusOnOpen.current) {
|
||||
contentRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
|
||||
didFocusOnOpen.current = true;
|
||||
}
|
||||
}, [open, position, keyboardNavigation]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -293,6 +308,46 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
role={keyboardNavigation ? 'menu' : undefined}
|
||||
onClickCapture={keyboardNavigation ? (event) => {
|
||||
if (event.target instanceof Element && event.target.closest('button:not(:disabled)')) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
} : undefined}
|
||||
onKeyDown={keyboardNavigation ? (event) => {
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'),
|
||||
);
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let nextIndex: number;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
nextIndex = (index + 1) % items.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
nextIndex = (index - 1 + items.length) % items.length;
|
||||
break;
|
||||
case 'Home':
|
||||
nextIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
nextIndex = items.length - 1;
|
||||
break;
|
||||
case 'Escape':
|
||||
case 'Tab':
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
items[nextIndex]?.focus();
|
||||
} : undefined}
|
||||
style={{
|
||||
top: position?.top ?? -9999,
|
||||
left: position?.left ?? -9999,
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import React, { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
MONITOR_CARD_HEIGHT,
|
||||
MONITOR_COLUMNS,
|
||||
monitorGridColumns,
|
||||
type MonitorColumn,
|
||||
} from "../../domain/monitorTableLayout";
|
||||
import { useMonitorTableColumns } from "../../application/state/useMonitorTableColumns";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuLabel,
|
||||
} from "../ui/context-menu";
|
||||
|
||||
export function MonitorHostCollection<T extends { id: string }>({
|
||||
items,
|
||||
view,
|
||||
layoutKey,
|
||||
pinnedId,
|
||||
renderCard,
|
||||
renderCells,
|
||||
rowProps,
|
||||
renderMenu,
|
||||
}: {
|
||||
items: T[];
|
||||
view: "list" | "charts";
|
||||
layoutKey: string;
|
||||
pinnedId?: string;
|
||||
renderCard: (item: T) => React.ReactNode;
|
||||
renderCells: (item: T) => Record<MonitorColumn, React.ReactNode>;
|
||||
rowProps: (item: T) => React.HTMLAttributes<HTMLTableRowElement>;
|
||||
renderMenu: (item: T) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(1248);
|
||||
const { widths, resize, startResize, hidden, setVisible } =
|
||||
useMonitorTableColumns();
|
||||
const visibleColumns = MONITOR_COLUMNS.filter(
|
||||
({ key }) => !hidden.includes(key),
|
||||
);
|
||||
const columns =
|
||||
view === "charts" ? monitorGridColumns(width, items.length) : 1;
|
||||
const scrollMargin = view === "charts" ? 16 : 56;
|
||||
const pinnedIndex = pinnedId
|
||||
? items.findIndex((item) => item.id === pinnedId)
|
||||
: -1;
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => items[index * columns]?.id ?? index,
|
||||
[items, columns],
|
||||
);
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLElement>({
|
||||
count: Math.ceil(items.length / columns),
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => (view === "charts" ? MONITOR_CARD_HEIGHT : 76),
|
||||
gap: view === "charts" ? 16 : 8,
|
||||
overscan: view === "charts" ? 2 : 5,
|
||||
getItemKey,
|
||||
scrollMargin,
|
||||
initialRect: { width: 1280, height: 800 },
|
||||
rangeExtractor: useCallback(
|
||||
(range) => {
|
||||
const visible = defaultRangeExtractor(range);
|
||||
const pinned = Math.floor(pinnedIndex / columns);
|
||||
return pinnedIndex < 0 || visible.includes(pinned)
|
||||
? visible
|
||||
: [...visible, pinned].sort((a, b) => a - b);
|
||||
},
|
||||
[pinnedIndex, columns],
|
||||
),
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const measure = () => setWidth(Math.max(0, element.clientWidth));
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [columns, view, hidden, virtualizer]);
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
virtualizer.scrollToOffset(0);
|
||||
}, [layoutKey, virtualizer]);
|
||||
const template = visibleColumns
|
||||
.map((column) => `${widths[column.key]}px`)
|
||||
.join(" ");
|
||||
const tableWidth = visibleColumns.reduce(
|
||||
(sum, column) => sum + widths[column.key],
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<div className="min-h-0 min-w-0 flex-1 px-4">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full min-w-0 overflow-auto"
|
||||
data-section="vault-monitor-list"
|
||||
data-monitor-virtual={view}
|
||||
>
|
||||
<div className="py-4">
|
||||
{view === "charts" ? (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
data-monitor-virtual-row={row.index}
|
||||
className="absolute left-0 top-0 grid w-full gap-4"
|
||||
style={{
|
||||
height: MONITOR_CARD_HEIGHT,
|
||||
gridTemplateColumns: `repeat(${columns},minmax(0,1fr))`,
|
||||
transform: `translateY(${row.start - scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{items
|
||||
.slice(row.index * columns, (row.index + 1) * columns)
|
||||
.map((item) => (
|
||||
<ContextMenu key={item.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{renderCard(item)}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{renderMenu(item)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<table
|
||||
className="block text-xs"
|
||||
style={{ width: tableWidth }}
|
||||
aria-rowcount={items.length + 1}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<thead className="sticky top-0 z-20 block bg-background text-muted-foreground">
|
||||
<tr
|
||||
className="grid h-10"
|
||||
style={{ gridTemplateColumns: template }}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
data-monitor-column={column.key}
|
||||
className={`relative flex min-w-0 items-center px-3 text-left text-[11px] font-medium${column.key === "terminal" ? " sticky right-0 z-10 bg-background" : ""}`}
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={t(`monitor.${column.key}`)}
|
||||
>
|
||||
{column.key === "terminal" ? (
|
||||
<span className="sr-only">
|
||||
{t("monitor.openTerminal")}
|
||||
</span>
|
||||
) : (
|
||||
t(`monitor.${column.key}`)
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("monitor.resizeColumn", {
|
||||
column: t(`monitor.${column.key}`),
|
||||
})}
|
||||
aria-valuenow={widths[column.key]}
|
||||
aria-valuemin={column.min}
|
||||
aria-valuemax={
|
||||
column.key === "terminal" ? 200 : 640
|
||||
}
|
||||
tabIndex={0}
|
||||
className="absolute inset-y-1 right-0 w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-1 after:right-0.5 after:w-px hover:after:bg-primary focus-visible:after:bg-primary"
|
||||
onPointerDown={(event) =>
|
||||
startResize(column.key, event)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
resize(column.key, column.width)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "ArrowRight"
|
||||
) {
|
||||
event.preventDefault();
|
||||
resize(
|
||||
column.key,
|
||||
widths[column.key] +
|
||||
(event.key === "ArrowRight" ? 1 : -1) *
|
||||
(event.shiftKey ? 32 : 8),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent data-monitor-column-menu>
|
||||
<ContextMenuLabel>
|
||||
{t("monitor.visibleColumns")}
|
||||
</ContextMenuLabel>
|
||||
{MONITOR_COLUMNS.map(({ key }) => (
|
||||
<ContextMenuCheckboxItem
|
||||
key={key}
|
||||
data-monitor-toggle-column={key}
|
||||
checked={!hidden.includes(key)}
|
||||
disabled={key === "host" || key === "terminal"}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(checked) =>
|
||||
setVisible(key, checked === true)
|
||||
}
|
||||
>
|
||||
{t(`monitor.${key}`)}
|
||||
</ContextMenuCheckboxItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<tbody
|
||||
className="relative block"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const cells = renderCells(items[row.index]);
|
||||
return (
|
||||
<ContextMenu key={row.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<tr
|
||||
{...rowProps(items[row.index])}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
aria-rowindex={row.index + 2}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: template,
|
||||
minHeight: 72,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: tableWidth,
|
||||
transform: `translateY(${row.start - scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map(({ key }) => (
|
||||
<React.Fragment key={key}>
|
||||
{cells[key]}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tr>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{renderMenu(items[row.index])}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
export function MonitorHostLatency({
|
||||
value,
|
||||
responseValue,
|
||||
}: {
|
||||
value?: number | null;
|
||||
responseValue?: number | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isValid = (n: number | null | undefined): n is number =>
|
||||
typeof n === "number" && Number.isFinite(n) && n >= 0;
|
||||
const usesResponse = !isValid(value) && isValid(responseValue);
|
||||
const latency = usesResponse ? responseValue : value;
|
||||
const known = isValid(latency);
|
||||
const bars = !known ? 0 : latency <= 100 ? 4 : latency <= 300 ? 2 : 1;
|
||||
const tone = !known
|
||||
? "text-muted-foreground"
|
||||
: bars === 4
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: bars === 2
|
||||
? "text-amber-500 dark:text-amber-400"
|
||||
: "text-red-600 dark:text-red-400";
|
||||
const label = `${t(usesResponse ? "monitor.responseLatency" : "monitor.latency")}: ${known ? `${Number(latency.toFixed(1))} ms` : "--"}`;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={`inline-flex h-5 shrink-0 items-center ${tone}`}
|
||||
data-monitor-latency
|
||||
data-monitor-signal-bars={bars}
|
||||
>
|
||||
<svg width="18" height="14" viewBox="0 0 18 14" aria-hidden="true">
|
||||
{[4, 7, 10, 13].map((height, index) => (
|
||||
<rect
|
||||
key={height}
|
||||
x={1 + index * 4}
|
||||
y={14 - height}
|
||||
width="3"
|
||||
height={height}
|
||||
rx="0.8"
|
||||
fill="currentColor"
|
||||
opacity={index < bars ? 1 : 0.18}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={tooltipClass}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipClass =
|
||||
"max-w-sm whitespace-pre-wrap break-words bg-popover text-popover-foreground shadow-md ring-1 ring-border/40";
|
||||
export function MonitorHostTags({ tags = [] }: { tags?: string[] }) {
|
||||
if (!tags.length)
|
||||
return (
|
||||
<span className="inline-flex h-5 items-center text-[10px] text-muted-foreground">
|
||||
--
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="flex h-5 min-w-0 max-w-full items-center gap-1 overflow-hidden"
|
||||
data-monitor-tags
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="inline-block max-w-20 truncate border-0 px-1.5 py-0 text-[10px] font-normal leading-5"
|
||||
>
|
||||
{tags[0]}
|
||||
</Badge>
|
||||
{tags.length > 1 && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
+{tags.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={tooltipClass}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="max-w-full break-all text-[10px] font-normal"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
export function MonitorHostNotes({
|
||||
notes,
|
||||
focusable = false,
|
||||
}: {
|
||||
notes?: string;
|
||||
focusable?: boolean;
|
||||
}) {
|
||||
if (!notes?.trim())
|
||||
return <span className="text-[10px] text-muted-foreground">--</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-monitor-notes
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
className="block min-w-0 truncate text-[10px] text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
||||
>
|
||||
{notes}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={`${tooltipClass} max-h-64 overflow-y-auto`}>
|
||||
{notes}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,26 @@ import type { Host } from "../../domain/models";
|
||||
import type { MonitorPoint } from "../../domain/monitorRuntime";
|
||||
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
import { MonitorHostLatency } from "./MonitorHostMetadata";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
|
||||
test("latency signals use the stats response when transport ping is unavailable and prefer valid ping readings", () => {
|
||||
for (const [ping, response, bars, label] of [
|
||||
[null, 145, "2", "Response latency (includes remote processing): 145 ms"],
|
||||
[0, 500, "4", "Latency: 0 ms"],
|
||||
[NaN, 450, "1", "Response latency (includes remote processing): 450 ms"],
|
||||
[null, null, "0", "Latency: --"],
|
||||
] as const) {
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><TooltipProvider>
|
||||
<MonitorHostLatency value={ping} responseValue={response} />
|
||||
</TooltipProvider></I18nProvider>,
|
||||
)).window.document;
|
||||
const signal = document.querySelector("[data-monitor-latency]")!;
|
||||
assert.equal(signal.getAttribute("data-monitor-signal-bars"), bars);
|
||||
assert.equal(signal.getAttribute("aria-label"), label);
|
||||
}
|
||||
});
|
||||
|
||||
const now = 1_800_000_000_000;
|
||||
const point = (ago: number, cpu: number | null): MonitorPoint => ({
|
||||
@@ -161,7 +181,7 @@ for (const view of ["list", "charts"] as const) {
|
||||
assert.equal(document.querySelector("header .lucide-gauge"), null);
|
||||
assert.equal(document.querySelector(".lucide-activity"), null);
|
||||
if (view === "list") {
|
||||
const network = document.querySelector("tbody tr")!.children[5];
|
||||
const network = document.querySelector("[data-monitor-cell=network]")!;
|
||||
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
|
||||
}
|
||||
});
|
||||
|
||||
+370
-261
@@ -6,6 +6,7 @@ import {
|
||||
Copy,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Gpu,
|
||||
HardDrive,
|
||||
LayoutGrid,
|
||||
List,
|
||||
@@ -38,6 +39,7 @@ import type { Host } from "../../domain/models";
|
||||
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "../../domain/monitorChart";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { ContextMenuItem } from "../ui/context-menu";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
@@ -70,6 +72,9 @@ import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
|
||||
import { toast } from "../ui/toast";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
import { MonitorHostCollection } from "./MonitorHostCollection";
|
||||
import { MonitorHostLatency, MonitorHostNotes, MonitorHostTags } from "./MonitorHostMetadata";
|
||||
|
||||
const emptyMonitorPoints: MonitorPoint[] = [];
|
||||
const percent = (n: number | null | undefined) =>
|
||||
@@ -214,22 +219,15 @@ export function MonitorPanels({
|
||||
} | null>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const [recovery, setRecovery] = useState<{ hostId: string; username: string; password: string } | null>(null);
|
||||
const [deleting, setDeleting] = useState<MonitorPanel | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [login, setLogin] = useState({
|
||||
hostId: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
useEffect(
|
||||
() => setLogin({ hostId: "", username: "", password: "" }),
|
||||
[detail],
|
||||
);
|
||||
const panel = panels.find((p) => p.id === selected) ?? panels[0];
|
||||
const running = !!panel && runtime.running.includes(panel.id);
|
||||
const startEdit = (base: MonitorPanel | null) => {
|
||||
setDetail(null);
|
||||
setRecovery(null);
|
||||
setSelecting(false);
|
||||
setError(null);
|
||||
setEditor({
|
||||
@@ -305,28 +303,39 @@ export function MonitorPanels({
|
||||
toast.error(t("monitor.saveFailed"));
|
||||
}
|
||||
};
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
);
|
||||
const orderedHostIds = useMemo(() => {
|
||||
if (!panel) return [];
|
||||
return panel.sort === "manual" ? panel.hostIds : [...panel.hostIds].sort((a, b) =>
|
||||
(hosts.find((host) => host.id === a)?.label ?? a).localeCompare(hosts.find((host) => host.id === b)?.label ?? b),
|
||||
);
|
||||
}, [panel, hosts]);
|
||||
return panel.sort === "manual"
|
||||
? panel.hostIds
|
||||
: [...panel.hostIds].sort((a, b) =>
|
||||
(hostById.get(a)?.label ?? a).localeCompare(
|
||||
hostById.get(b)?.label ?? b,
|
||||
),
|
||||
);
|
||||
}, [panel, hostById]);
|
||||
const rows = useMemo(() => {
|
||||
if (!panel) return [];
|
||||
const q = search.trim().toLocaleLowerCase();
|
||||
const rows = orderedHostIds.map((id) => ({
|
||||
id,
|
||||
host: hosts.find((h) => h.id === id),
|
||||
host: hostById.get(id),
|
||||
state: monitorStateSince(runtime.hosts[id], runtime.startedAt[panel.id]),
|
||||
}));
|
||||
return rows.filter(
|
||||
({ host }) => !q ||
|
||||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""}`
|
||||
({ host }) =>
|
||||
!q ||
|
||||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""} ${host?.tags.join(" ") ?? ""} ${host?.notes ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(q),
|
||||
);
|
||||
}, [panel, orderedHostIds, hosts, runtime, search]);
|
||||
const dragProps = (hostId: string): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
|
||||
}, [panel, orderedHostIds, hostById, runtime, search]);
|
||||
const dragProps = (
|
||||
hostId: string,
|
||||
): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
|
||||
draggable: true,
|
||||
"data-monitor-host": hostId,
|
||||
onDragStart: (event) => {
|
||||
@@ -336,7 +345,12 @@ export function MonitorPanels({
|
||||
setDragging({ panelId: panel.id, hostId });
|
||||
},
|
||||
onDragOver: (event) => {
|
||||
if (!panel || dragging?.panelId !== panel.id || dragging.hostId === hostId) return;
|
||||
if (
|
||||
!panel ||
|
||||
dragging?.panelId !== panel.id ||
|
||||
dragging.hostId === hostId
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(hostId);
|
||||
@@ -344,10 +358,11 @@ export function MonitorPanels({
|
||||
onDrop: (event) => {
|
||||
if (!panel || dragging?.panelId !== panel.id) return;
|
||||
event.preventDefault();
|
||||
if (dragging.hostId !== hostId) void patchPanel({
|
||||
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
|
||||
sort: "manual",
|
||||
});
|
||||
if (dragging.hostId !== hostId)
|
||||
void patchPanel({
|
||||
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
|
||||
sort: "manual",
|
||||
});
|
||||
setDragging(null);
|
||||
setDropTarget(null);
|
||||
},
|
||||
@@ -362,18 +377,24 @@ export function MonitorPanels({
|
||||
: !running
|
||||
? "stopped"
|
||||
: state?.status === "live" &&
|
||||
Date.now() - state.at > Math.max(5000, (panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500)
|
||||
Date.now() - state.at >
|
||||
Math.max(
|
||||
5000,
|
||||
(panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500,
|
||||
)
|
||||
? "stale"
|
||||
: (state?.status ?? "queued");
|
||||
const stateBadge = (host: Host | undefined, state?: MonitorHostState) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
|
||||
"inline-flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
|
||||
status(host, state) === "live"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
: ["error", "disconnected", "missing"].includes(status(host, state))
|
||||
? "text-destructive"
|
||||
: ["login", "stale", "unsupported", "limit"].includes(status(host, state))
|
||||
: ["login", "stale", "unsupported", "limit"].includes(
|
||||
status(host, state),
|
||||
)
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
@@ -382,7 +403,9 @@ export function MonitorPanels({
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 rounded-full bg-current"
|
||||
/>
|
||||
{t(`monitor.status.${status(host, state)}`)}
|
||||
<span className="truncate">
|
||||
{t(`monitor.status.${status(host, state)}`)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
const renderMetrics = (
|
||||
@@ -445,6 +468,10 @@ export function MonitorPanels({
|
||||
};
|
||||
const hostDetail = hosts.find((h) => h.id === detail);
|
||||
const detailState = detail && panel ? monitorStateSince(runtime.hosts[detail], runtime.startedAt[panel.id]) : undefined;
|
||||
const detailAvailable = status(hostDetail, detailState) === "live";
|
||||
useEffect(() => {
|
||||
if (detail && !detailAvailable) setDetail(null);
|
||||
}, [detail, detailAvailable]);
|
||||
return (
|
||||
<section
|
||||
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden"
|
||||
@@ -647,35 +674,76 @@ export function MonitorPanels({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto" data-section="vault-monitor-list">
|
||||
<div className="p-4">
|
||||
{!rows.length ? (
|
||||
<p className="p-8 text-center text-sm text-muted-foreground">
|
||||
{t("monitor.noMatches")}
|
||||
</p>
|
||||
) : panel.view === "charts" ? (
|
||||
<div className="grid auto-rows-fr grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-4">
|
||||
{rows.map(({ id, host, state }) => (
|
||||
{!rows.length ? (
|
||||
<div className="flex-1 overflow-auto p-12 text-center text-sm text-muted-foreground">
|
||||
{t("monitor.noMatches")}
|
||||
</div>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<MonitorHostCollection
|
||||
items={rows}
|
||||
view={panel.view}
|
||||
layoutKey={`${panel.id}:${panel.view}:${search}`}
|
||||
pinnedId={
|
||||
dragging?.panelId === panel.id ? dragging.hostId : undefined
|
||||
}
|
||||
renderMenu={({ id, host, state }) => (
|
||||
<>
|
||||
<ContextMenuItem disabled={!host} onSelect={() => host && onConnect(host)}>
|
||||
{t("monitor.openTerminal")}
|
||||
</ContextMenuItem>
|
||||
{host && running && ["login", "error", "disconnected"].includes(status(host, state)) && (
|
||||
<ContextMenuItem data-monitor-retry={id} onSelect={() => setRecovery({hostId: id, username: host.username ?? "", password: ""})}>
|
||||
{t("monitor.retry")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
rowProps={({ id, host, state }) => ({
|
||||
...dragProps(id),
|
||||
className: cn(
|
||||
"group cursor-default [&>td]:bg-card/70 hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td]:min-w-0 [&>td]:px-3 [&>td]:py-3 [&>td]:flex [&>td]:items-center",
|
||||
dragging?.panelId === panel.id &&
|
||||
dragging.hostId === id &&
|
||||
"cursor-grabbing opacity-40",
|
||||
dropTarget === id && "[&>td]:bg-accent",
|
||||
status(host, state) !== "live" && "text-muted-foreground opacity-50",
|
||||
),
|
||||
})}
|
||||
renderCard={({ id, host, state }) => (
|
||||
<article
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"relative h-full min-w-0 overflow-hidden rounded-xl bg-card/70 shadow-sm shadow-black/[0.02] transition-colors hover:bg-card",
|
||||
dragging?.panelId === panel.id &&
|
||||
dragging.hostId === id &&
|
||||
"cursor-grabbing opacity-40",
|
||||
dropTarget === id && "bg-accent",
|
||||
status(host, state) !== "live" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
key={id}
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"flex h-full min-w-0 cursor-default flex-col overflow-hidden rounded-xl bg-card/70 p-4 text-left shadow-sm shadow-black/[0.02] transition-colors hover:bg-card focus-visible:outline focus-visible:outline-primary",
|
||||
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
|
||||
dropTarget === id && "bg-accent",
|
||||
status(host, state) !== "live" &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
type="button"
|
||||
className="flex h-full w-full min-w-0 cursor-default flex-col p-4 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
aria-disabled={status(host, state) !== "live"}
|
||||
onClick={() => {
|
||||
if (status(host, state) !== "live") return;
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="mb-2 flex w-full min-w-0 items-center gap-3 pr-7">
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={host.label} size="log" />
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label}
|
||||
size="log"
|
||||
/>
|
||||
) : (
|
||||
<Server size={24} className="shrink-0 text-muted-foreground" />
|
||||
<Server
|
||||
size={24}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
@@ -685,20 +753,39 @@ export function MonitorPanels({
|
||||
{host?.hostname ?? id}
|
||||
</p>
|
||||
</div>
|
||||
{stateBadge(host, state)}
|
||||
</div>
|
||||
<div className={cn(status(host, state) !== "live" && "opacity-60")}>
|
||||
<div className="mb-2 flex h-5 w-full min-w-0 items-center gap-2">
|
||||
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<MonitorHostTags tags={host?.tags} />
|
||||
</div>
|
||||
<div className="min-w-0 max-w-[40%]">
|
||||
{stateBadge(host, state)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
status(host, state) !== "live" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{renderMetrics(state)}
|
||||
<div className="mt-4">
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<HardDrive size={13} className="text-amber-600 dark:text-amber-400" />
|
||||
<HardDrive
|
||||
size={13}
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<span>{t("monitor.disks")}</span>
|
||||
</div>
|
||||
<MonitorDiskUsage stats={state?.stats} limit={2} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
|
||||
<span className="flex items-center gap-1.5 py-1">
|
||||
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
<ArrowDown
|
||||
size={13}
|
||||
className="shrink-0 text-sky-600 dark:text-sky-400"
|
||||
/>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netRxSpeed
|
||||
@@ -706,7 +793,10 @@ export function MonitorPanels({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 py-1">
|
||||
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
|
||||
<ArrowUp
|
||||
size={13}
|
||||
className="shrink-0 text-violet-600 dark:text-violet-400"
|
||||
/>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netTxSpeed
|
||||
@@ -715,127 +805,169 @@ export function MonitorPanels({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-auto flex items-center gap-1.5 pt-4 text-[10px] text-muted-foreground">
|
||||
<Clock3 size={11} />
|
||||
{state?.at
|
||||
? t("monitor.updatedAt", {
|
||||
time: new Date(state.at).toLocaleTimeString(),
|
||||
})
|
||||
: t("monitor.noSamples")}
|
||||
</p>
|
||||
<div className="mt-auto flex w-full min-w-0 items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MonitorHostNotes notes={host?.notes} />
|
||||
</div>
|
||||
<span className="inline-flex shrink-0 items-center gap-1">
|
||||
<Clock3 size={11} />
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: t("monitor.noSamples")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[840px] border-separate border-spacing-x-0 border-spacing-y-2 text-xs">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
{[
|
||||
"host",
|
||||
"status",
|
||||
"cpu",
|
||||
"memory",
|
||||
"disks",
|
||||
"network",
|
||||
"updated",
|
||||
].map((k) => (
|
||||
<th
|
||||
key={k}
|
||||
className="px-3 py-3 text-left font-medium"
|
||||
>
|
||||
{t(`monitor.${k}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ id, host, state }) => {
|
||||
const s = state?.stats;
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"cursor-default [&>td]:bg-card/70 [&>td]:transition-colors hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td:last-child]:rounded-r-xl",
|
||||
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
|
||||
dropTarget === id && "[&>td]:bg-accent",
|
||||
status(host, state) !== "live" &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<button
|
||||
draggable
|
||||
className="flex cursor-inherit items-center gap-2.5 text-left hover:underline"
|
||||
onClick={() => {
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={host.label} size="md" />
|
||||
) : (
|
||||
<Server size={24} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 max-w-52">
|
||||
<div className="truncate font-medium">
|
||||
{host?.label ?? t("monitor.missingHost")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{host?.hostname ?? id}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3">
|
||||
{stateBadge(host, state)}
|
||||
</td>
|
||||
<td className="px-3 tabular-nums">
|
||||
<ResourceBar label="" value={s?.cpu} />
|
||||
</td>
|
||||
<td className="px-3 tabular-nums">
|
||||
<ResourceBar
|
||||
label=""
|
||||
value={
|
||||
s?.memTotal && s.memUsed != null
|
||||
? (s.memUsed / s.memTotal) * 100
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="min-w-[220px] px-3 py-3">
|
||||
<MonitorDiskUsage stats={s} />
|
||||
</td>
|
||||
<td className="px-3 tabular-nums whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
<span className="sr-only">{t("monitor.download")}: </span>
|
||||
{rate(s?.networkReady ? s.netRxSpeed : null)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
|
||||
<span className="sr-only">{t("monitor.upload")}: </span>
|
||||
{rate(s?.networkReady ? s.netTxSpeed : null)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 whitespace-nowrap">
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: "--"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-4 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{t("monitor.memoryOnlyHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-3 top-3 h-7 w-7 text-muted-foreground"
|
||||
data-monitor-terminal={id}
|
||||
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
|
||||
title={t("monitor.openTerminal")}
|
||||
disabled={!host}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (host) onConnect(host);
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
</Button>
|
||||
</article>
|
||||
)}
|
||||
renderCells={({ id, host, state }) => ({
|
||||
host: (<td>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2.5 text-left aria-disabled:no-underline hover:underline"
|
||||
aria-disabled={status(host, state) !== "live"}
|
||||
onClick={() => {
|
||||
if (status(host, state) !== "live") return;
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
{host ? (
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label}
|
||||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<Server
|
||||
size={24}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{host?.label ?? t("monitor.missingHost")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{host?.hostname ?? id}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>),
|
||||
latency: (<td>
|
||||
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
|
||||
</td>),
|
||||
tags: (<td>
|
||||
<MonitorHostTags tags={host?.tags} />
|
||||
</td>),
|
||||
notes: (<td>
|
||||
<div className="min-w-0 w-full">
|
||||
<MonitorHostNotes notes={host?.notes} focusable />
|
||||
</div>
|
||||
</td>),
|
||||
cpu: (<td className="tabular-nums">
|
||||
<ResourceBar
|
||||
className="w-full"
|
||||
label=""
|
||||
value={state?.stats?.cpu}
|
||||
/>
|
||||
</td>),
|
||||
memory: (<td className="tabular-nums">
|
||||
<ResourceBar
|
||||
className="w-full"
|
||||
label=""
|
||||
value={
|
||||
state?.stats?.memTotal &&
|
||||
state.stats.memUsed != null
|
||||
? (state.stats.memUsed / state.stats.memTotal) *
|
||||
100
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</td>),
|
||||
disks: (<td>
|
||||
<div className="w-full min-w-0">
|
||||
<MonitorDiskUsage stats={state?.stats} />
|
||||
</div>
|
||||
</td>),
|
||||
network: (<td
|
||||
data-monitor-cell="network"
|
||||
className="tabular-nums whitespace-nowrap"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowDown
|
||||
size={13}
|
||||
className="shrink-0 text-sky-600 dark:text-sky-400"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{t("monitor.download")}:{" "}
|
||||
</span>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netRxSpeed
|
||||
: null,
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowUp
|
||||
size={13}
|
||||
className="shrink-0 text-violet-600 dark:text-violet-400"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{t("monitor.upload")}:{" "}
|
||||
</span>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netTxSpeed
|
||||
: null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>),
|
||||
updated: (<td className="whitespace-nowrap tabular-nums">
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: "--"}
|
||||
</td>),
|
||||
status: (<td>{stateBadge(host, state)}</td>),
|
||||
terminal: (<td className="sticky right-0 z-10 justify-center !bg-background before:pointer-events-none before:absolute before:inset-0 before:rounded-r-xl before:bg-card/70 group-hover:before:bg-card">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-7 w-7 text-muted-foreground"
|
||||
data-monitor-terminal={id}
|
||||
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
|
||||
title={t("monitor.openTerminal")}
|
||||
disabled={!host}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (host) onConnect(host);
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
</Button>
|
||||
</td>)
|
||||
})}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1006,8 +1138,9 @@ export function MonitorPanels({
|
||||
className={monitorAsideClass}
|
||||
/>
|
||||
)}
|
||||
{detail && (
|
||||
{detail && detailAvailable && (
|
||||
<AsidePanel
|
||||
dataSection="vault-monitor-detail"
|
||||
open
|
||||
onClose={() => setDetail(null)}
|
||||
title={hostDetail?.label ?? t("monitor.missingHost")}
|
||||
@@ -1019,7 +1152,7 @@ export function MonitorPanels({
|
||||
width="w-[380px]"
|
||||
className={monitorAsideClass}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<AsidePanelContent scrollMode="native">
|
||||
<div className="flex items-center gap-3">
|
||||
{hostDetail && <DistroAvatar host={hostDetail} fallback={hostDetail.label} size="lg" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1027,73 +1160,41 @@ export function MonitorPanels({
|
||||
{stateBadge(hostDetail, detailState)}
|
||||
</div>
|
||||
</div>
|
||||
{["login", "error", "disconnected"].includes(
|
||||
detailState?.status ?? "",
|
||||
) && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.loginHint")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monitor-login-user">
|
||||
{t("monitor.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="monitor-login-user"
|
||||
autoComplete="off"
|
||||
value={
|
||||
login.hostId === detail
|
||||
? login.username
|
||||
: (hostDetail?.username ?? "")
|
||||
}
|
||||
onChange={(e) =>
|
||||
setLogin((prev) => ({
|
||||
hostId: detail,
|
||||
username: e.target.value,
|
||||
password: prev.hostId === detail ? prev.password : "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="monitor-login-password">
|
||||
{t("monitor.password")}
|
||||
</Label>
|
||||
<Input
|
||||
id="monitor-login-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={login.hostId === detail ? login.password : ""}
|
||||
onChange={(e) =>
|
||||
setLogin((prev) => ({
|
||||
hostId: detail,
|
||||
username:
|
||||
prev.hostId === detail
|
||||
? prev.username
|
||||
: (hostDetail?.username ?? ""),
|
||||
password: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
monitorActions.retry(
|
||||
detail,
|
||||
login.hostId === detail && login.password
|
||||
? {
|
||||
username: login.username,
|
||||
password: login.password,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setLogin({ hostId: "", username: "", password: "" });
|
||||
}}
|
||||
>
|
||||
{t("monitor.retry")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{renderMetrics(detailState, true)}
|
||||
{detailState?.stats && (
|
||||
detailState.stats.gpu != null ||
|
||||
detailState.stats.gpuName ||
|
||||
(detailState.stats.gpuMemTotal ?? 0) > 0
|
||||
) && (
|
||||
<section className="space-y-3" data-monitor-gpu>
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Gpu size={15} className="text-muted-foreground" />
|
||||
{t("systemManager.tabs.gpu")}
|
||||
</h3>
|
||||
{detailState.stats.gpuName && (
|
||||
<p className="break-words text-xs text-muted-foreground">
|
||||
{detailState.stats.gpuName}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] text-muted-foreground">{t("systemManager.gpu.util")}</p>
|
||||
<ResourceBar label="" value={detailState.stats.gpu} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{t("systemManager.gpu.memory")}</span>
|
||||
<span className="tabular-nums">
|
||||
{capacity(detailState.stats.gpuMemUsed)} / {capacity(detailState.stats.gpuMemTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<ResourceBar label="" value={
|
||||
detailState.stats.gpuMemUsed != null && (detailState.stats.gpuMemTotal ?? 0) > 0
|
||||
? detailState.stats.gpuMemUsed / detailState.stats.gpuMemTotal! * 100
|
||||
: null
|
||||
} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.observed", {
|
||||
count: detailState?.points.length
|
||||
@@ -1188,24 +1289,6 @@ export function MonitorPanels({
|
||||
<p>{detailState?.stats?.kernelRelease ?? "--"}</p>
|
||||
<p>{detailState?.stats?.hostname ?? "--"}</p>
|
||||
</div>
|
||||
{detailState?.status === "unsupported" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.unsupportedHint")}
|
||||
</p>
|
||||
)}
|
||||
{!hostDetail && panel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void patchPanel({
|
||||
hostIds: panel.hostIds.filter((id) => id !== detail),
|
||||
});
|
||||
setDetail(null);
|
||||
}}
|
||||
>
|
||||
{t("monitor.removeHost")}
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<Button
|
||||
@@ -1219,6 +1302,32 @@ export function MonitorPanels({
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
)}
|
||||
<Dialog open={!!recovery} onOpenChange={(open) => !open && setRecovery(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitor.retry")}</DialogTitle>
|
||||
<DialogDescription>{t("monitor.loginHint")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!recovery) return;
|
||||
monitorActions.retry(recovery.hostId, recovery.password ? {username: recovery.username, password: recovery.password} : undefined);
|
||||
setRecovery(null);
|
||||
}} className="space-y-4">
|
||||
<p className="truncate text-sm font-medium">{hostById.get(recovery?.hostId ?? "")?.label}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monitor-login-user">{t("monitor.username")}</Label>
|
||||
<Input id="monitor-login-user" autoComplete="off" value={recovery?.username ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, username: event.target.value})} />
|
||||
<Label htmlFor="monitor-login-password">{t("monitor.password")}</Label>
|
||||
<Input id="monitor-login-password" type="password" autoComplete="new-password" value={recovery?.password ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, password: event.target.value})} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRecovery(null)}>{t("common.cancel")}</Button>
|
||||
<Button type="submit">{t("monitor.retry")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(open) => !open && setDeleting(null)}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Catty prompt cache repair and verification
|
||||
|
||||
## Scope and issue history
|
||||
|
||||
- Original report: [#3545](https://github.com/binaricat/Netcatty/issues/3545), duplicate [#3546](https://github.com/binaricat/Netcatty/issues/3546).
|
||||
- [#3547](https://github.com/binaricat/Netcatty/pull/3547) moved static guidelines before scope/session details. That repair remains valid, but its closing comment did not claim a measured DeepSeek result.
|
||||
- This change starts from main `b5fee9632ea012e5c15fbdae454dec2aa7d32572` and repairs additional cache invalidation and usage-reporting paths.
|
||||
|
||||
## Confirmed defects and changes
|
||||
|
||||
1. Completed terminal results were rewritten when the next user turn rebuilt history. Keep the already bounded result, with secret redaction, and let context budgeting own necessary reductions.
|
||||
2. Live SDK results use JSON while saved history uses text. Apply the same bounded compression to both before sending them, avoiding a second, different representation on the next turn.
|
||||
3. Routine output cleanup inserted the latest goal and pending handles at the start of otherwise intact history. Reserve that reinjection for actual budget pressure or summarization.
|
||||
4. Large-output handle notices were replaced at the front on every step. Results already containing a handle need no duplicate notice. Announce missing handles once at the tail and preserve prior notices; continue to reserve their budget if older results are pruned.
|
||||
5. DeepSeek's documented `prompt_cache_hit_tokens` was lost when the OpenAI-compatible SDK parsed responses. Normalize it to the standard cached-token field before parsing, preserving an existing standard value, including zero. Cover streaming and non-streaming responses.
|
||||
6. Catty omitted cache counts from its usage events and saved assistant messages. Preserve provider counts and attach aggregate usage after the final assistant message has been created.
|
||||
7. Native Anthropic requests lacked cache breakpoints. Cache the static system block and a bounded conversation tail; leave implicit-cache OpenAI/Google requests untouched and preserve signed reasoning content. Enable these vendor fields only for the official HTTPS Anthropic endpoint (including the SDK's default endpoint), not third-party Messages-compatible gateways. Use the shared resolved SDK address, including OpenRouter/Ollama fallbacks when a stored URL is empty. Review follow-ups add endpoint gating and full-driver gateway/fallback regressions; they do not change the measured DeepSeek path.
|
||||
|
||||
## Comparison with mature agents and official guidance
|
||||
|
||||
- [Codex prompt-caching tests](https://github.com/openai/codex/blob/8c076942cb70fba8e5c4732c23f1faabc4aed484/codex-rs/core/tests/suite/prompt_caching.rs): exercise stable tools and append-only request prefixes. The new tests compare the actual serialized requests produced by Catty's driver, SDK and bridge adapter across consecutive turns.
|
||||
- [OpenCode cache policy](https://github.com/anomalyco/opencode/blob/c38d83c0c4f92466ff9c8e12b8a741698e811012/packages/ai/src/cache-policy.ts): use provider-specific explicit cache boundaries where required and leave implicit-cache protocols alone.
|
||||
- [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/): exact shared prefixes, asynchronous/best-effort cache persistence and provider-reported hit/miss counts. Live probes wait between turns and use the official endpoint.
|
||||
- [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching): explicit boundaries, tool/system/message ordering and a limit of four breakpoints. Catty uses at most three automatic markers.
|
||||
- [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching): stable shared prefixes. No additional explicit cache-control fields are sent on this path.
|
||||
|
||||
## Live verification
|
||||
|
||||
The final fixed Catty driver was bundled from the isolated worktree and executed inside the running Electron renderer, using the app's existing IPC and configured `https://api.deepseek.com` service, model `deepseek-v4-flash`. The API key stayed inside the app's credential handling.
|
||||
|
||||
Each run contains six consecutive turns for each of three scenarios: plain chat, one command per turn, and a command producing 14,000 synthetic log lines followed by a bounded saved-output read. Model requests and usage are real. Tool execution uses an in-memory fixture; no command is sent to the user's connected SSH hosts. The prompts contain only synthetic session/host information.
|
||||
|
||||
The results file records every provider-reported input/cache/output count, per-step counts and errors. The rate is total cached input divided by total input, including all model calls in a turn. It is not an average of rounded percentages.
|
||||
|
||||
Two complete runs of the final source produced 36 turns / 72 real model requests, with zero errors:
|
||||
|
||||
| Scenario | Turns | Weighted cache hit rate | Per-turn range |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Plain chat | 12 | 98.55% | 98.19%-98.92% |
|
||||
| Command execution | 12 | 98.59% | 98.12%-99.03% |
|
||||
| Large command output + saved-output read | 12 | 97.80% | 97.30%-98.32% |
|
||||
|
||||
Raw per-turn and per-step counters are in [the JSON evidence file](./catty-prompt-cache-live-2026-10-18.json). These are warm-cache measurements; an earlier probe's first request correctly reported zero cached input before subsequent requests reached about 98%. The earlier probe is excluded from the final-source totals.
|
||||
|
||||
The production chat-message component was rendered with the live conversation in Electron. Its usage footer showed input, output, cache and reasoning counts; opening the command entry showed the expected fixture result. No product UI layout was changed.
|
||||
|
||||
Limits: live provider evidence covers the configured DeepSeek model. Native Anthropic request formatting and usage preservation are verified with the real SDK and a protocol fixture, not a paid Claude session. Cache hits remain provider-managed; a new prefix, necessary compaction or expired cache can legitimately miss.
|
||||
|
||||
## Automated verification
|
||||
|
||||
- Full AI/history/UI-related suite after the endpoint review follow-ups: 600 tests passed.
|
||||
- New regression coverage includes six consecutive plain-chat turns, six command turns, six large-file turns, six large-terminal-output turns, long-output handles, streaming/non-streaming DeepSeek usage, aggregate multi-step usage and native Anthropic boundaries.
|
||||
- Production build passed.
|
||||
- Full lint passed with eight existing warnings; final changed-file lint passed without warnings.
|
||||
- Type checking matches the untouched main baseline: 758 existing errors, zero additional errors after normalizing checkout paths and line numbers.
|
||||
|
||||
Full-repository verification also found setup and unrelated timing limits:
|
||||
|
||||
- The first run passed 13,968 tests and skipped 36, with one failed file caused by the fresh worktree missing the nested `plugin-cli/node_modules/yauzl` dependency. Linking the existing dependency directory repaired the setup; all 29 CLI tests then passed.
|
||||
- A repeat full run hit the unchanged `large directory progress coalesces synchronous persistence work` timing assertion (2,638,624 characters persisted against a 588,644-character snapshot). That assertion passed in the prior full run. All 102 tests in its file passed on isolated rerun; the same assertion also passed on untouched main. This is an observed full-run timing fluctuation, not a claimed baseline reproduction or an SFTP repair.
|
||||
- The Homebrew Node 22 binary is broken locally (missing shared library); verification uses an isolated official npm Node 22 runtime. `TMPDIR=/tmp` selects Netcatty's shorter private per-user temporary directory for Unix-socket tests. The default macOS path exceeded the FIDO test socket limit; no product source was changed to accommodate this environment.
|
||||
|
||||
The full suite is therefore not reported as unconditionally green. No Catty/cache test failed in the final implementation.
|
||||
@@ -0,0 +1,955 @@
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"endpoint": "https://api.deepseek.com",
|
||||
"model": "deepseek-v4-flash",
|
||||
"input": "synthetic prompts and in-memory tool output",
|
||||
"totalTurns": 36,
|
||||
"totalModelCalls": 72,
|
||||
"summary": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"turns": 12,
|
||||
"inputTokens": 162870,
|
||||
"cachedTokens": 160512,
|
||||
"weightedHitRate": 98.55,
|
||||
"minTurnRate": 98.19,
|
||||
"maxTurnRate": 98.92
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"turns": 12,
|
||||
"inputTokens": 341856,
|
||||
"cachedTokens": 337024,
|
||||
"weightedHitRate": 98.59,
|
||||
"minTurnRate": 98.12,
|
||||
"maxTurnRate": 99.03
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"turns": 12,
|
||||
"inputTokens": 598639,
|
||||
"cachedTokens": 585472,
|
||||
"weightedHitRate": 97.8,
|
||||
"minTurnRate": 97.3,
|
||||
"maxTurnRate": 98.32
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"rows": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"inputTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.61,
|
||||
"outputTokens": 21,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 690
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"inputTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.4,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 898
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"inputTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.19,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 658
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"inputTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.92,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 982
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"inputTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.71,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 611
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"inputTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.5,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 676
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"inputTokens": 27473,
|
||||
"cachedTokens": 27008,
|
||||
"hitRate": 98.31,
|
||||
"outputTokens": 384,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 1,
|
||||
"milliseconds": 5711
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"inputTokens": 28156,
|
||||
"cachedTokens": 27776,
|
||||
"hitRate": 98.65,
|
||||
"outputTokens": 93,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 4606
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"inputTokens": 28538,
|
||||
"cachedTokens": 28160,
|
||||
"hitRate": 98.68,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 3,
|
||||
"milliseconds": 4738
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"inputTokens": 28940,
|
||||
"cachedTokens": 28544,
|
||||
"hitRate": 98.63,
|
||||
"outputTokens": 113,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5000
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"inputTokens": 29342,
|
||||
"cachedTokens": 29056,
|
||||
"hitRate": 99.03,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 5,
|
||||
"milliseconds": 5011
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"inputTokens": 29714,
|
||||
"cachedTokens": 29312,
|
||||
"hitRate": 98.65,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 4517
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"inputTokens": 42570,
|
||||
"cachedTokens": 41472,
|
||||
"hitRate": 97.42,
|
||||
"outputTokens": 344,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 6895
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"inputTokens": 45647,
|
||||
"cachedTokens": 44416,
|
||||
"hitRate": 97.3,
|
||||
"outputTokens": 163,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5644
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"inputTokens": 48547,
|
||||
"cachedTokens": 47488,
|
||||
"hitRate": 97.82,
|
||||
"outputTokens": 165,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 5271
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"inputTokens": 51449,
|
||||
"cachedTokens": 50304,
|
||||
"hitRate": 97.77,
|
||||
"outputTokens": 163,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 8,
|
||||
"milliseconds": 5826
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"inputTokens": 54353,
|
||||
"cachedTokens": 53248,
|
||||
"hitRate": 97.97,
|
||||
"outputTokens": 166,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 10,
|
||||
"milliseconds": 6235
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"inputTokens": 57280,
|
||||
"cachedTokens": 56320,
|
||||
"hitRate": 98.32,
|
||||
"outputTokens": 168,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 12,
|
||||
"milliseconds": 5692
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"promptTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 21
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"promptTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"promptTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"promptTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"promptTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13531,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 364
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13942,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 14018,
|
||||
"cachedTokens": 13824,
|
||||
"completionTokens": 73
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 14138,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14214,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14324,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14400,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 93
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14540,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14616,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14726,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14802,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14912,
|
||||
"cachedTokens": 14720,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 243
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14394,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14618,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 14705,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15360,
|
||||
"cachedTokens": 14720,
|
||||
"completionTokens": 96
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15582,
|
||||
"cachedTokens": 15232,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 15669,
|
||||
"cachedTokens": 15488,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16326,
|
||||
"cachedTokens": 15616,
|
||||
"completionTokens": 98
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16552,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 16639,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17294,
|
||||
"cachedTokens": 16640,
|
||||
"completionTokens": 96
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17516,
|
||||
"cachedTokens": 17280,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 17603,
|
||||
"cachedTokens": 17408,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18261,
|
||||
"cachedTokens": 17536,
|
||||
"completionTokens": 99
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18489,
|
||||
"cachedTokens": 18304,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 18576,
|
||||
"cachedTokens": 18432,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19236,
|
||||
"cachedTokens": 18560,
|
||||
"completionTokens": 101
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19468,
|
||||
"cachedTokens": 19328,
|
||||
"completionTokens": 4
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"rows": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"inputTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.61,
|
||||
"outputTokens": 57,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 1278
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"inputTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.4,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 658
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"inputTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.19,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 809
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"inputTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.92,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 958
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"inputTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.71,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 857
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"inputTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.5,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 1061
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"inputTokens": 27300,
|
||||
"cachedTokens": 26880,
|
||||
"hitRate": 98.46,
|
||||
"outputTokens": 195,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 1,
|
||||
"milliseconds": 4773
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"inputTokens": 27785,
|
||||
"cachedTokens": 27264,
|
||||
"hitRate": 98.12,
|
||||
"outputTokens": 84,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 4529
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"inputTokens": 28142,
|
||||
"cachedTokens": 27776,
|
||||
"hitRate": 98.7,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 3,
|
||||
"milliseconds": 4584
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"inputTokens": 28482,
|
||||
"cachedTokens": 28032,
|
||||
"hitRate": 98.42,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 4706
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"inputTokens": 28822,
|
||||
"cachedTokens": 28416,
|
||||
"hitRate": 98.59,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 5,
|
||||
"milliseconds": 4558
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"inputTokens": 29162,
|
||||
"cachedTokens": 28800,
|
||||
"hitRate": 98.76,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 4496
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"inputTokens": 42428,
|
||||
"cachedTokens": 41344,
|
||||
"hitRate": 97.45,
|
||||
"outputTokens": 270,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 5945
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"inputTokens": 45447,
|
||||
"cachedTokens": 44416,
|
||||
"hitRate": 97.73,
|
||||
"outputTokens": 164,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5125
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"inputTokens": 48356,
|
||||
"cachedTokens": 47232,
|
||||
"hitRate": 97.68,
|
||||
"outputTokens": 166,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 5238
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"inputTokens": 51279,
|
||||
"cachedTokens": 50176,
|
||||
"hitRate": 97.85,
|
||||
"outputTokens": 167,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 8,
|
||||
"milliseconds": 5535
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"inputTokens": 54195,
|
||||
"cachedTokens": 53120,
|
||||
"hitRate": 98.02,
|
||||
"outputTokens": 164,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 10,
|
||||
"milliseconds": 5977
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"inputTokens": 57088,
|
||||
"cachedTokens": 55936,
|
||||
"hitRate": 97.98,
|
||||
"outputTokens": 162,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 12,
|
||||
"milliseconds": 5391
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"promptTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 57
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"promptTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"promptTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"promptTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"promptTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13531,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 191
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13769,
|
||||
"cachedTokens": 13568,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 13829,
|
||||
"cachedTokens": 13568,
|
||||
"completionTokens": 80
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 13956,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14016,
|
||||
"cachedTokens": 13824,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14126,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14186,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14296,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14356,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14466,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14526,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14636,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 166
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14320,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 100
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14550,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 14637,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15293,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15517,
|
||||
"cachedTokens": 15360,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 15604,
|
||||
"cachedTokens": 15360,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16262,
|
||||
"cachedTokens": 15616,
|
||||
"completionTokens": 99
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16490,
|
||||
"cachedTokens": 16256,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 16577,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17236,
|
||||
"cachedTokens": 16512,
|
||||
"completionTokens": 100
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17466,
|
||||
"cachedTokens": 17280,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 17553,
|
||||
"cachedTokens": 17408,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18209,
|
||||
"cachedTokens": 17536,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18433,
|
||||
"cachedTokens": 18176,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 18520,
|
||||
"cachedTokens": 18304,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19174,
|
||||
"cachedTokens": 18432,
|
||||
"completionTokens": 95
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19394,
|
||||
"cachedTokens": 19200,
|
||||
"completionTokens": 4
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Chat history search responsiveness
|
||||
|
||||
The search introduced in #3601 ran its entire bounded-content scan inside the
|
||||
history drawer render. The index cache limits retained memory, but histories
|
||||
larger than that cache still rebuilt older indexes on each keystroke. A changing
|
||||
streamed conversation also refreshed the session list and repeated that scan.
|
||||
|
||||
Search now runs in scheduled slices of at most 16 sessions or approximately
|
||||
4 ms. The existing per-session text limits, matching rules, index-memory bounds
|
||||
and session order remain unchanged. A query change cancels the previous scan;
|
||||
streaming updates keep match decisions for unchanged sessions and recheck the
|
||||
replacement objects, so ongoing output cannot repeatedly restart the search.
|
||||
The drawer shows a searching status and clears obsolete-query results until the
|
||||
current query finishes. While the session list changes, only still-present,
|
||||
unchanged matched objects stay visible, in the current list order; removed or
|
||||
replaced objects cannot remain selectable until the next slice. Clearing the
|
||||
query restores the unfiltered history
|
||||
immediately and releases the search's previous results.
|
||||
|
||||
## Verification
|
||||
|
||||
A separate Electron window mounted the actual before/after history drawer with
|
||||
500 synthetic conversations, each containing about 64,000 retained characters.
|
||||
No user conversations, terminals or provider calls were used. The same input
|
||||
and ten updates to a single conversation produced:
|
||||
|
||||
| Work | Before | After |
|
||||
| --- | ---: | ---: |
|
||||
| Input handler return | 352.7 ms | 4.0 ms |
|
||||
| Single conversation update | 319.4–323.3 ms | 1.49–1.90 ms |
|
||||
| Final results for the fixture query | 1 matching conversation | Same conversation |
|
||||
|
||||
The old input/update tasks each blocked for over 300 ms; the repaired search
|
||||
produced no task of 50 ms or longer in this run. These are development-fixture
|
||||
measurements on one machine, not a guarantee for every device or history size.
|
||||
Native input, empty results and the matching conversation were also checked in
|
||||
the rendered window.
|
||||
|
||||
The drawer regression drives the actual input event and verifies that a queued
|
||||
event-loop heartbeat runs before any retained message is indexed. It fails on
|
||||
the previous implementation (720 synchronous message reads) and passes after
|
||||
the repair. Further tests cover obsolete queries, live replacements/removals,
|
||||
continuous-stream progress, result order and the existing matching limits.
|
||||
|
||||
```sh
|
||||
node --test --import tsx \
|
||||
components/AIChatSessionHistoryDrawer.test.tsx \
|
||||
components/AIChatSessionHistoryDrawer.responsiveness.test.tsx \
|
||||
application/state/sessionHistorySearch.test.ts
|
||||
```
|
||||
|
||||
## Pending-result freshness
|
||||
|
||||
A follow-up review found that the first repair could temporarily show results
|
||||
from the previous session snapshot. A second Electron comparison used the
|
||||
initial repair (`a8b291b7c`) and the reconciled version. Removing or replacing the
|
||||
matching session left it selectable in the initial repair's first commit; the
|
||||
reconciled version immediately removed it. Reversing the current list initially
|
||||
kept session 0 first; it now immediately shows session 499 first. Unchanged
|
||||
confirmed matches remain visible while changed objects are checked, so a stream
|
||||
does not blank every result. This check compares object references and ordering
|
||||
without rereading retained message text. Input remained about 4 ms for the same
|
||||
500-conversation fixture.
|
||||
@@ -0,0 +1,95 @@
|
||||
# SFTP browser window performance
|
||||
|
||||
## Finding
|
||||
|
||||
The production file-list hook rendered every file until both row and viewport
|
||||
measurements arrived. It also disabled its window when a pane became inactive.
|
||||
The production tree rendered every descriptor whenever its viewport height was
|
||||
zero, including the first commit and a hidden tree's ResizeObserver update.
|
||||
Thus opening or leaving a large directory could mount thousands of file rows.
|
||||
Both conditions are already present in v1.1.83; this is an existing bottleneck
|
||||
found during the post-release performance audit, not a regression attributed to
|
||||
the newer compact-list UI.
|
||||
|
||||
An isolated Electron renderer using the actual list/sorting/files hooks, list
|
||||
component, tree component and application CSS reproduced:
|
||||
|
||||
| Files | Initial list mount | Hide list | Hidden list rows |
|
||||
| --- | --- | --- | --- |
|
||||
| 1,000 | 293 ms | 129 ms | 1,001 |
|
||||
| 5,000 | 1,309 ms | 696 ms | 5,001 |
|
||||
|
||||
The 5,000-file tree initially took 604 ms and mounted 5,001 rows again when
|
||||
hidden. The extra row is the normal parent-directory entry. These are controlled
|
||||
macOS measurements, not the original Windows environment from #3155.
|
||||
|
||||
## Repair and regression gate
|
||||
|
||||
Large lists retain their window while inactive or unmeasured. A conservative
|
||||
initial file-row height permits a bounded first commit; the existing real-row
|
||||
measurement still corrects it for either density. Tree rows already have a fixed
|
||||
height, so a zero viewport simply keeps the overscan window. Small directories
|
||||
and the complete file/selection data remain unchanged.
|
||||
|
||||
`components/sftp/SftpPaneVirtualization.test.ts` failed on both original hooks
|
||||
with 5,000 initial rows, then passed after the repair. It checks first commits,
|
||||
inactive/zero-height panes, the last file, density remeasurement, small lists and
|
||||
tree visibility. The separate native script exercises the actual rendered
|
||||
components and records timings without machine-dependent time assertions.
|
||||
|
||||
## Durable native acceptance
|
||||
|
||||
Run `node_modules/.bin/electron scripts/sftp-browser-window-performance.live.test.cjs`.
|
||||
For the red control, also set `NETCATTY_SFTP_WINDOW_BASELINE=1` and
|
||||
`NETCATTY_SFTP_WINDOW_BASELINE_REF=b073162eafd39ce2a337c9acbf36da2eb7456f63`.
|
||||
The control overrides only the two hooks, keeping the same inputs and current
|
||||
components/CSS. It completes the interaction checks, then fails with
|
||||
`SFTP_FILE_WINDOW_UNBOUNDED` (maximum 5,003 mounted rows). The repaired version
|
||||
passes, retaining at most 39 rows across every recorded commit. Initially hidden
|
||||
list/tree panes retain at most 22/32 rows; the small directory keeps all 22 rows.
|
||||
|
||||
In this Vite development-renderer fixture, initial list mount was 2,975 -> 61ms,
|
||||
first hide 1,950 -> 11ms, restore 148 -> 12ms, and initial tree 715 -> 18ms.
|
||||
These timings are distinct from the earlier bundled-renderer probe above.
|
||||
The deterministic gate is bounded mounted rows, rather than a hardware-dependent
|
||||
time threshold. Actual application CSS row heights are asserted at 44/24px.
|
||||
|
||||
The native script scrolls to the final file with Electron wheel events, selects
|
||||
and opens files, expands a tree directory and opens its child, toggles density,
|
||||
and hides/restores both views after selecting the final file. Selection and
|
||||
position persist (one CSS-pixel boundary tolerance). It uses its own sandboxed
|
||||
BrowserWindow/profile, dedicated port 5379 and private Vite cache, allows only
|
||||
its local fixture requests, and closes its listener/profile afterward. Results
|
||||
and window pixels are kept in Netcatty's dedicated temporary directory. This is
|
||||
programmatic native-window input; it does not use the user's SSH or unlock the
|
||||
macOS session, and is not a claim of manual CUA acceptance while macOS was locked.
|
||||
|
||||
## Review follow-up: shorter background listings
|
||||
|
||||
Review found that a retained scroll offset could outlive a shorter listing.
|
||||
The browser clamps its real scroll position, but inactive-list scroll events
|
||||
are ignored; the calculated start could exceed the final file and render an
|
||||
empty slice. The same range issue affects tree descriptors. Clamp both windows
|
||||
to the available content, and synchronize the measured scroll position when
|
||||
positive viewport geometry returns. Preserve the last visible offset while
|
||||
geometry is zero.
|
||||
|
||||
Both hook tests failed at the exact shrink scenario before this follow-up.
|
||||
The native control loading all three original product files from `fa5e79c85`
|
||||
also failed: list 5,000 -> 100 files and tree 100 -> 60 each recorded zero rows
|
||||
during the hidden update and first restore commit. On this macOS run, later
|
||||
browser scroll events automatically recovered; a permanent blank pane was not
|
||||
observed. The repaired run retains 21 list rows and 6 -> 31 tree rows across
|
||||
those commits, without another user scroll. It asserts nonempty bounded rows,
|
||||
the last file's visibility and each rendered row's position against the actual
|
||||
browser-clamped scroll offset. The previous complete native interactions also
|
||||
remain passing. To replay this narrower red control, use the same baseline
|
||||
environment variables with `NETCATTY_SFTP_WINDOW_BASELINE_REF=fa5e79c85`.
|
||||
|
||||
181 related SFTP checks, full lint (zero errors, eight existing warnings) and
|
||||
production build pass locally. Full-project typechecking still reports existing
|
||||
errors; there are no diagnostics in the changed product/test/fixture files.
|
||||
|
||||
The directory-window problem is independent of the expanded transfer-center
|
||||
problem fixed in #3722. Related to #3155 only; neither observation establishes
|
||||
the cause of the original Windows report, so this PR does not close it.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Expanded SFTP folder-list responsiveness
|
||||
|
||||
The global transfer center already limited mounted top-level rows, but expanding
|
||||
a folder rendered every retained child file. Each progress update also rebuilt
|
||||
all folder groups and sorted their children. A folder with thousands of queued
|
||||
files therefore stalled the renderer even when only one file's bytes changed.
|
||||
|
||||
The child list now mounts its visible range with a small overscan. Its existing
|
||||
scroll area still reaches every retained file. The transfer center reads a stable
|
||||
hierarchy snapshot between lifecycle changes; mounted parent/child rows subscribe
|
||||
to their own live progress. Progress that introduces a row or changes its status
|
||||
still refreshes the hierarchy. Completion retention and compact checkpoints are
|
||||
unchanged.
|
||||
|
||||
## Controlled measurements
|
||||
|
||||
A separate Electron window mounted the actual before/after transfer center with
|
||||
two synthetic directory transfers. No SSH connection, real file or user transfer
|
||||
was involved. The same expand action, ten updates to one child and a scroll to
|
||||
the final file were exercised in each version.
|
||||
|
||||
| Children | Work | Before | After |
|
||||
| ---: | --- | ---: | ---: |
|
||||
| 1,000 | Expand to next rendered frame | 298.8 ms | 10.94 ms |
|
||||
| 1,000 | One child's progress to next frame | 112.6–125.6 ms | 1.18–18.18 ms |
|
||||
| 5,000 | Expand to next rendered frame | 1,216.1 ms | 14.36 ms |
|
||||
| 5,000 | One child's progress to next frame | 500.5–538.4 ms | 1.31–6.03 ms |
|
||||
| 1,000 / 5,000 | Mounted child rows | 1,000 / 5,000 | 12 / 12 |
|
||||
|
||||
Both repaired lists reached their final child and retained all 1,000 / 5,000
|
||||
queued records. Native scrolling, collapse/reopen and live byte display were
|
||||
also checked. These are development-fixture measurements on one machine, not a
|
||||
cross-device frame-rate guarantee.
|
||||
|
||||
The actual-component regression fails on the original implementation because
|
||||
it mounts 1,000 children. It verifies bounded mounted rows, live progress without
|
||||
re-sorting the folder, final-child access, completion accounting and collapse /
|
||||
reopen. The existing top-level recycling test also expands a folder, scrolls it
|
||||
out of view and returns to its retained expansion state.
|
||||
|
||||
## Issue relationship
|
||||
|
||||
Related to #3155. Its Windows report and subsequent comments cover multiple
|
||||
possible large-transfer failures; they do not establish that this exact expanded
|
||||
folder-list workload caused the reporter's freeze. This repair is independently
|
||||
reproduced UI work and must not close that issue. Open #3694 and #3696 modify
|
||||
file metadata / transfer connection acquisition, not this list behavior.
|
||||
|
||||
## Control availability follow-up
|
||||
|
||||
Independent review found that the initial stable snapshot did not refresh when
|
||||
progress reported a transfer could no longer pause, including a child's change
|
||||
propagated to its folder. Metadata-only patches and a newly available failed
|
||||
transfer checkpoint could similarly leave batch buttons stale. The list now
|
||||
refreshes its lightweight snapshot only when grouping/order/control eligibility
|
||||
fields change; repeated identical capability metadata on byte ticks keeps the
|
||||
hierarchy stable.
|
||||
|
||||
The actual-component regression first failed with Pause all incorrectly enabled
|
||||
after the backend reported the folder could not pause. It now covers that child /
|
||||
parent update, direct metadata changes, repeated unchanged metadata without a
|
||||
full sort, and Resume all becoming available when a failed task gains a checkpoint.
|
||||
An Electron comparison against initial repair 1b96e27b0 confirmed stale batch
|
||||
buttons before and correct disabled/enabled states afterward. The final 1,000 /
|
||||
5,000 file runs still mounted 12 rows and reached the final file: expand-to-frame
|
||||
12.6 / 12.86 ms, child progress 1.32–15.42 / 1.06–17.90 ms.
|
||||
|
||||
|
||||
## Hierarchy repair follow-up
|
||||
|
||||
A same-byte background event can repair a formerly top-level child before the
|
||||
progress de-duplication guard returns. The repaired hierarchy must be published
|
||||
even when the remaining event is ignored, including late events for terminal
|
||||
rows. Those early exits now publish only when repair actually replaced the row;
|
||||
normal progress still builds at most one hierarchy snapshot and ordinary byte
|
||||
ticks keep its reference stable.
|
||||
|
||||
Both the store and actual-center regressions failed before this repair. The
|
||||
Electron comparison against e894c9cb confirmed the old folder had no Show files
|
||||
action after the same-byte event or its next byte tick. The repaired folder
|
||||
immediately exposes its child, and the next byte tick keeps that grouping
|
||||
without rebuilding it. A fresh 5,000-file run mounted 12 rows, reached the final
|
||||
file and retained all 5,000 records: expansion 14.12 ms, progress 1.28-18.58 ms.
|
||||
|
||||
|
||||
The repaired hierarchy also uses the existing coalesced persistence scheduler
|
||||
at the structural mutation, so an ignored terminal event cannot leave the old
|
||||
orphan on disk indefinitely. A restart regression first failed with zero writes
|
||||
for the repair; completed, failed and cancelled cases now each save the change
|
||||
once, restore under the folder (completed children through the existing compact
|
||||
checkpoint), and schedule no further writes on unchanged late progress. Owner
|
||||
lifecycle notifications stay at zero. An isolated actual Electron renderer
|
||||
compared 744f0f8e against the repair and confirmed all three old orphan restores
|
||||
and all three repaired restores. This run used synthetic in-memory persistence;
|
||||
manual window input was unavailable because macOS was locked. Earlier native
|
||||
folder scrolling and appearance evidence remains separate.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Pending sync indicator performance
|
||||
|
||||
The pending-local-sync indicator independently collected and serialized the full sync payload after every local edit. Upload debounce did not cover this path. New monitor panel and portable chat changes also trigger it. No competing open repair was found during the 2026-10-18 audit.
|
||||
|
||||
A production React hook in an isolated Electron renderer, with 2,000 synthetic hosts and mocked cloud network edges, received 50 monitor-name edits spaced 5 ms apart:
|
||||
|
||||
| Checkpoint | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Collections during the edit burst | 50 | 0 |
|
||||
| Collections for the settled indicator | 0 additional | 1 |
|
||||
| Uploads after the existing upload debounce | 1 | 1 |
|
||||
| Pending indicator after edits | true | true |
|
||||
|
||||
The indicator now waits 200 ms after the latest change before collecting. Disabling, locking, losing the provider, pausing, or unmounting cancels pending work; stale in-flight indicator results are ignored. Startup baseline collection and explicit/manual sync keep their existing timing. The change does not alter cloud payloads or conflict/merge decisions.
|
||||
|
||||
`application/state/useAutoSync.monitorPanels.test.ts` exercises the actual hook, adapter notifications, and sidecar collection boundary. It failed before the fix (13 collections vs 3 startup collections after ten edits), and passes after the fix. It also checks settled pending state, cancellation before collection, and cancellation while collection is in flight. Existing panel create/edit/delete upload and sync baseline tests continue to pass.
|
||||
|
||||
Validation: 47 related tests passed, full lint has zero errors (eight existing warnings), and production build passed. Full-platform CI must be checked at the final PR head. The fixture does not claim to reproduce the Windows long-running terminal report or to benchmark a real cloud provider.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Terminal argument completion performance
|
||||
|
||||
Issue: #3715. This follows #3623's argument-position matching for #3622; it does not resolve #3622's remaining interactive-email UX request.
|
||||
|
||||
`matchesWordPrefixQuery` tried pinyin for every word after a failed literal prefix comparison. A large snippet library therefore repeated transliteration/cache work for ordinary ASCII command words on each completion query. Printable ASCII words cannot gain another pinyin prefix, so they now return a miss immediately after the literal check. Chinese and other non-ASCII words keep the existing path.
|
||||
|
||||
## Verification
|
||||
|
||||
The regression exercises the actual `getCompletions` argument path with 5,000 snippets. Before the fix, it fails because the pinyin cache contains 5,011 entries, including ASCII commands and numbers. Afterward, only the Chinese label is cached and its initials still find the snippet. Additional cases cover empty queries, case, fullwidth normalization, word boundaries, Chinese full pinyin/initials, and mixed ASCII/Chinese words.
|
||||
|
||||
Run the real Electron renderer measurement:
|
||||
|
||||
```sh
|
||||
node_modules/.bin/electron scripts/terminal-snippet-completion-performance.live.test.cjs
|
||||
```
|
||||
|
||||
This uses isolated application data and synthetic snippets, calls the production completion engine, measures completion time and a queued input timer, and checks that ASCII words bypass pinyin while Chinese initials still work. It prints timings; the regression gate is the unnecessary-work assertion, avoiding a machine-specific timing threshold.
|
||||
|
||||
On the audit machine, an initial fixture with Chinese labels and ASCII commands measured:
|
||||
|
||||
| Library | Before | After |
|
||||
| --- | --- | --- |
|
||||
| 100 snippets | 0.4–2.4 ms | 0.1–1.3 ms |
|
||||
| 1,000 snippets | 4.2–7.0 ms | 1.7–2.3 ms |
|
||||
| 5,000 snippets | 52.9–86.0 ms | 8.8–9.9 ms |
|
||||
|
||||
The corresponding 5,000-entry queued input delay fell from 53.1–86.1 ms to 8.8–9.9 ms. The committed fixture uses ASCII labels plus one Chinese label; the final three rounds measured 6.5–11.0 ms. These are local completion/render-thread measurements, not network or terminal throughput measurements.
|
||||
|
||||
An isolated real Electron window also mounted production `TerminalAutocomplete` with real xterm and 5,001 snippets. Typing `git pull yx` displayed the Chinese mailbox snippet; clicking it selected the correct snippet. No connected SSH server, personal snippets, or user settings were used.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Vault host search performance
|
||||
|
||||
Issue #3720 covers a large-library input stall already present in v1.1.83. It is separate from the snippet argument-completion repair in #3717. The recent IP sort is not the cause: A–Z sorting reproduces the same delay.
|
||||
|
||||
## Cause and scope
|
||||
|
||||
`getHostSearchMatch` evaluates matching/ranking against host label, group, tags and hostname. After literal and compact comparisons fail, human fields may also use pinyin. Printable ASCII fields cannot gain another match from that fallback, but repeated queries still performed its conversion/cache operations for thousands of hosts.
|
||||
|
||||
The repair only skips that redundant host-field fallback. Literal and compact matching remain ahead of it; source preference, phases, scores, Chinese/mixed-language matching, IP isolation, usernames/notes fallback, sorting and rendered fields are unchanged. The generic search and snippet matchers are not modified.
|
||||
|
||||
## Reproduction and verification
|
||||
|
||||
```sh
|
||||
node_modules/.bin/electron scripts/vault-host-search-performance.live.test.cjs
|
||||
```
|
||||
|
||||
The isolated Electron fixture renders production `useVaultHostCollections` and `VirtualizedHostCollection` with 5,000 synthetic hosts across 100 groups. It changes the actual controlled input through React `onChange`, repeats 63 input events over three rounds, measures render/input-task delay and compares result counts/order fingerprints. Only 15 host rows are mounted. It does not access user hosts, SSH servers or settings.
|
||||
|
||||
The real input regression was observed failing before the source change: warmed `Region 99` events delayed the next input turn by 100.2 and 89.6 ms. After the repair they took 24.2 and 23.2 ms. The standalone measurement uses a 50 ms warmed-input gate; slower test machines can set `NETCATTY_VAULT_SEARCH_MAX_INPUT_MS` explicitly. It is skipped under ordinary Node tests, where real renderer timings are unavailable.
|
||||
|
||||
| Query | Before render task | After render task | Matches |
|
||||
| --- | --- | --- | --- |
|
||||
| `production web` | 59.4–62.8 ms | 17.8–21.2 ms | 5,000 |
|
||||
| `Region 99` | 89.1–99.5 ms | 20.2–23.6 ms | 95 |
|
||||
| `no-match-query` | 36.4–90.4 ms | 13.8–17.1 ms | 0 |
|
||||
|
||||
All 63 before/after input events retained the same result count and complete-order fingerprint. `Region 99` returning 95 hosts is existing multi-segment matching behavior; this repair does not reinterpret it as a group-only filter.
|
||||
|
||||
Separate four-round diagnosis compared current source, v1.1.83 source and A–Z/IP sorting. Release source reproduced 56.4–59.2 ms for `production web` and 90.5–101.4 ms for `Region 99`, confirming an existing bottleneck rather than a newly introduced regression. These are representative local renderer measurements, not a guarantee for every host library or device.
|
||||
|
||||
Semantic tests cover ASCII separator compaction and punctuation, fullwidth normalization, direct IP matches and cross-field isolation, strict source preference, Chinese/mixed-language pinyin, and complete matching phase/method/score details. They also pass against the original implementation.
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BrowserBookmark } from './browserBookmark';
|
||||
|
||||
export class HostBrowserIconError extends Error {
|
||||
constructor(public readonly reason: 'file-type' | 'file-size' | 'decode') {
|
||||
super(reason);
|
||||
this.name = 'HostBrowserIconError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface HostBrowserTab {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
loading: boolean;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
iconDataUrl: string | null;
|
||||
iconGlyph: string | null;
|
||||
iconColor: string | null;
|
||||
}
|
||||
|
||||
export interface HostBrowserState {
|
||||
hostId: string;
|
||||
label: string;
|
||||
route: 'connecting' | 'connected' | 'disconnected';
|
||||
error: string | null;
|
||||
activeTabId: string | null;
|
||||
tabs: HostBrowserTab[];
|
||||
}
|
||||
|
||||
export interface HostBrowserDownload {
|
||||
id: string;
|
||||
hostId: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
savePath: string;
|
||||
status: 'waiting' | 'progressing' | 'completed' | 'cancelled' | 'interrupted';
|
||||
received: number;
|
||||
total: number;
|
||||
startedAt: number;
|
||||
endedAt: number | null;
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export type HostBrowserBookmark = BrowserBookmark & {
|
||||
siteIconDataUrl?: string | null;
|
||||
siteIconGlyph?: string | null;
|
||||
siteIconColor?: string | null;
|
||||
};
|
||||
|
||||
export type HostBrowserCommand =
|
||||
| { type: 'new-tab'; url?: string }
|
||||
| { type: 'navigate'; url: string; tabId?: string }
|
||||
| { type: 'activate' | 'close-tab' | 'back' | 'forward' | 'reload' | 'stop' | 'reset-icon'; tabId?: string }
|
||||
| { type: 'reorder'; tabId: string; beforeTabId: string }
|
||||
| { type: 'set-icon'; tabId: string; dataUrl?: string | null; glyph: string; color: string }
|
||||
| { type: 'retry' };
|
||||
|
||||
export type HostBrowserShortcut = { type: 'focus-address' | 'new-tab' | 'close-tab' | 'reload'; tabId: string };
|
||||
export type HostBrowserPanelEvent = { browserId: string } & (
|
||||
| { type: 'state'; state: HostBrowserState }
|
||||
| { type: 'downloads'; downloads: HostBrowserDownload[]; startedId?: string }
|
||||
| { type: 'bookmarks' }
|
||||
| { type: 'shortcut'; shortcut: HostBrowserShortcut }
|
||||
);
|
||||
@@ -22,6 +22,8 @@ export type MonitorHostState = {
|
||||
| "disconnected"
|
||||
| "limit";
|
||||
at: number;
|
||||
/** GPU publication can advance independently of the primary sample/history. */
|
||||
statsAt?: number;
|
||||
/** Omitted by incremental replies when the receiver already has this sample. */
|
||||
stats?: MonitorStats | null;
|
||||
points: MonitorPoint[];
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
MONITOR_COLUMNS,
|
||||
clampMonitorColumnWidth,
|
||||
normalizeMonitorColumnWidths,
|
||||
normalizeMonitorHiddenColumns,
|
||||
monitorGridColumns,
|
||||
} from "./monitorTableLayout";
|
||||
|
||||
test("saved column sizes cannot hide data or create unbounded table widths", () => {
|
||||
const widths = normalizeMonitorColumnWidths({
|
||||
host: -1,
|
||||
latency: Infinity,
|
||||
notes: 100000,
|
||||
terminal: NaN,
|
||||
unknown: 400,
|
||||
});
|
||||
assert.equal(widths.host, 160);
|
||||
assert.equal(widths.latency, 90);
|
||||
assert.equal(widths.notes, 640);
|
||||
assert.equal(widths.terminal, 56);
|
||||
assert.equal(Object.keys(widths).length, MONITOR_COLUMNS.length);
|
||||
assert.deepEqual(
|
||||
normalizeMonitorColumnWidths(null),
|
||||
normalizeMonitorColumnWidths({}),
|
||||
);
|
||||
for (const column of MONITOR_COLUMNS) {
|
||||
assert.equal(clampMonitorColumnWidth(column.key, -100), column.min);
|
||||
assert.equal(
|
||||
clampMonitorColumnWidth(column.key, 10000),
|
||||
column.key === "terminal" ? 200 : 640,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("virtual card rows adapt to narrow panes and do not allocate empty columns", () => {
|
||||
assert.equal(monitorGridColumns(200, 1000), 1);
|
||||
assert.equal(monitorGridColumns(575, 1000), 1);
|
||||
assert.equal(monitorGridColumns(576, 1000), 2);
|
||||
assert.equal(monitorGridColumns(1248, 1000), 4);
|
||||
assert.equal(monitorGridColumns(1248, 2), 2);
|
||||
assert.equal(monitorGridColumns(0, 1000), 1);
|
||||
});
|
||||
|
||||
test("hidden columns always preserve host and terminal and discard unknown settings", () => {
|
||||
assert.deepEqual(
|
||||
normalizeMonitorHiddenColumns([
|
||||
"host",
|
||||
"terminal",
|
||||
"cpu",
|
||||
"cpu",
|
||||
"unknown",
|
||||
"status",
|
||||
]),
|
||||
["cpu", "status"],
|
||||
);
|
||||
assert.deepEqual(normalizeMonitorHiddenColumns(null), []);
|
||||
assert.deepEqual(normalizeMonitorHiddenColumns({ cpu: true }), []);
|
||||
assert.equal(
|
||||
normalizeMonitorHiddenColumns(MONITOR_COLUMNS.map((c) => c.key)).length,
|
||||
MONITOR_COLUMNS.length - 2,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export const MONITOR_COLUMNS = [
|
||||
{ key: "host", width: 220, min: 160 },
|
||||
{ key: "latency", width: 90, min: 80 },
|
||||
{ key: "tags", width: 150, min: 90 },
|
||||
{ key: "notes", width: 180, min: 110 },
|
||||
{ key: "cpu", width: 120, min: 120 },
|
||||
{ key: "memory", width: 120, min: 120 },
|
||||
{ key: "disks", width: 240, min: 220 },
|
||||
{ key: "network", width: 150, min: 140 },
|
||||
{ key: "updated", width: 110, min: 95 },
|
||||
{ key: "status", width: 100, min: 90 },
|
||||
{ key: "terminal", width: 56, min: 56 },
|
||||
] as const;
|
||||
export type MonitorColumn = (typeof MONITOR_COLUMNS)[number]["key"];
|
||||
export type MonitorColumnWidths = Record<MonitorColumn, number>;
|
||||
export const MONITOR_CARD_HEIGHT = 368;
|
||||
|
||||
export function clampMonitorColumnWidth(
|
||||
key: MonitorColumn,
|
||||
width: number,
|
||||
): number {
|
||||
const column = MONITOR_COLUMNS.find((column) => column.key === key)!;
|
||||
return Number.isFinite(width)
|
||||
? Math.round(
|
||||
Math.max(column.min, Math.min(key === "terminal" ? 200 : 640, width)),
|
||||
)
|
||||
: column.width;
|
||||
}
|
||||
export function normalizeMonitorColumnWidths(
|
||||
value: unknown,
|
||||
): MonitorColumnWidths {
|
||||
const stored =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return Object.fromEntries(
|
||||
MONITOR_COLUMNS.map((column) => [
|
||||
column.key,
|
||||
clampMonitorColumnWidth(
|
||||
column.key,
|
||||
typeof stored[column.key] === "number"
|
||||
? (stored[column.key] as number)
|
||||
: column.width,
|
||||
),
|
||||
]),
|
||||
) as MonitorColumnWidths;
|
||||
}
|
||||
export function monitorGridColumns(width: number, count: number): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(count, Math.floor((Math.max(0, width) + 16) / 296)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Only optional columns can be hidden, including when restoring old or corrupt settings. */
|
||||
export function normalizeMonitorHiddenColumns(value: unknown): MonitorColumn[] {
|
||||
return MONITOR_COLUMNS.filter(
|
||||
({ key }) =>
|
||||
key !== "host" &&
|
||||
key !== "terminal" &&
|
||||
Array.isArray(value) &&
|
||||
value.includes(key),
|
||||
).map(({ key }) => key);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getHostSearchMatch,
|
||||
matchesHostSearchQuery,
|
||||
matchesSearchQuery,
|
||||
matchesWordPrefixQuery,
|
||||
matchesWorkspaceHostPickerQuery,
|
||||
resetPinyinCacheForTests,
|
||||
} from "../lib/searchMatcher.ts";
|
||||
@@ -274,3 +275,66 @@ test("pinyin cache stays hard-bounded by retained characters, not just entries",
|
||||
assert.ok(getPinyinCacheStatsForTests().size > 0);
|
||||
resetPinyinCacheForTests();
|
||||
});
|
||||
|
||||
test("host ASCII fallback preserves compact matches and their complete ranking details", () => {
|
||||
const separators = ["-", "_", " ", "/", "\\", "|", ".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "<", ">", '"', "'", "`", "~"];
|
||||
for (const separator of separators) {
|
||||
assert.deepEqual(getHostSearchMatch("prefixsuffix", { label: `prefix${separator}suffix` }), {
|
||||
matched: true, phase: "loose", score: 100525,
|
||||
details: [{ segment: "prefixsuffix", field: "label", method: "compact", phase: "loose" }],
|
||||
}, JSON.stringify(separator));
|
||||
}
|
||||
for (const separator of ["+", "&", "@", "=", "%", "$", "#", "^"]) {
|
||||
assert.deepEqual(getHostSearchMatch("prefixsuffix", { label: `prefix${separator}suffix` }), {
|
||||
matched: false, phase: "none", score: 0, details: [],
|
||||
}, JSON.stringify(separator));
|
||||
}
|
||||
assert.deepEqual(getHostSearchMatch("PRODAPI", { label: "PROD-API" }), {
|
||||
matched: true, phase: "loose", score: 100525,
|
||||
details: [{ segment: "prodapi", field: "label", method: "compact", phase: "loose" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("host ASCII bypass preserves strict source selection, IP isolation, and pinyin scoring", () => {
|
||||
const host = { label: "Production API-01", group: "Region 99", hostname: "10.6.1.88", tags: ["production", "web", "核心"] };
|
||||
assert.deepEqual(getHostSearchMatch("production web", host), {
|
||||
matched: true, phase: "strict", score: 1000820,
|
||||
details: [
|
||||
{ segment: "production", field: "label", method: "literal", phase: "strict" },
|
||||
{ segment: "web", field: "tag", method: "literal", phase: "strict" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(getHostSearchMatch("10.6.1.88", host), {
|
||||
matched: true, phase: "strict", score: 1000430,
|
||||
details: [{ segment: "10.6.1.88", field: "hostname", method: "literal", phase: "strict" }],
|
||||
});
|
||||
for (const query of ["61", "production 10.6"]) {
|
||||
assert.deepEqual(getHostSearchMatch(query, host), { matched: false, phase: "none", score: 0, details: [] });
|
||||
}
|
||||
for (const [query, method, score] of [["hexin", "pinyinFull", 100232], ["hx", "pinyinInitials", 100228]] as const) {
|
||||
assert.deepEqual(getHostSearchMatch(query, host), {
|
||||
matched: true, phase: "loose", score,
|
||||
details: [{ segment: query, field: "tag", method, phase: "loose" }],
|
||||
});
|
||||
}
|
||||
assert.deepEqual(getHostSearchMatch("apibu", { label: "API部署" }), {
|
||||
matched: true, phase: "loose", score: 100512,
|
||||
details: [{ segment: "apibu", field: "label", method: "pinyinFull", phase: "loose" }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("word prefixes retain literal, normalized, and non-ASCII matching", () => {
|
||||
assert.equal(matchesWordPrefixQuery("", "deploy"), true);
|
||||
assert.equal(matchesWordPrefixQuery("DEP", "deploy-service"), true);
|
||||
assert.equal(matchesWordPrefixQuery("ser", "deploy-service"), true);
|
||||
assert.equal(matchesWordPrefixQuery("123", "item-1234"), true);
|
||||
assert.equal(matchesWordPrefixQuery("dep", "DEPLOY"), true);
|
||||
assert.equal(matchesWordPrefixQuery("caf", "café"), true);
|
||||
assert.equal(matchesWordPrefixQuery("mail", "gmail"), false);
|
||||
assert.equal(matchesWordPrefixQuery("zz", "deploy-service"), false);
|
||||
assert.equal(matchesWordPrefixQuery("--de", "deploy-service"), false);
|
||||
assert.equal(matchesWordPrefixQuery("bushu", "部署服务"), true);
|
||||
assert.equal(matchesWordPrefixQuery("bsfw", "部署服务"), true);
|
||||
assert.equal(matchesWordPrefixQuery("apibu", "API部署"), true);
|
||||
});
|
||||
@@ -167,10 +167,18 @@ module.exports = {
|
||||
'!node_modules/@cursor/sdk/dist/**/*.d.ts.map',
|
||||
// sqlite3 rebuilds a native module for Electron; its upstream source
|
||||
// tarball is build-time payload only.
|
||||
'!node_modules/sqlite3/deps/**/*'
|
||||
'!node_modules/sqlite3/deps/**/*',
|
||||
// Native compilation helpers are needed during install/rebuild only.
|
||||
// Match nested copies too; keep the actual native loaders and binaries.
|
||||
'!node_modules/**/node-gyp/**/*',
|
||||
'!node_modules/**/node-addon-api/**/*',
|
||||
'!node_modules/**/nan/**/*'
|
||||
],
|
||||
asarUnpack: [
|
||||
'node_modules/node-pty/**/*',
|
||||
// afterPack removes prebuilds for other platforms/architectures. Keep
|
||||
// these outside ASAR so removing them also removes their payload.
|
||||
'node_modules/@serialport/bindings-cpp/prebuilds/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@vscode/windows-process-tree/**/*',
|
||||
|
||||
@@ -254,17 +254,14 @@ function startPtyJob(ptyStream, command, options) {
|
||||
startupTimeoutMs: maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs,
|
||||
});
|
||||
|
||||
// Deliver the interrupt as terminal data (ETX). This matches manual Ctrl+C
|
||||
// in the terminal and terminalBridge.interruptSession: the remote line
|
||||
// discipline turns \x03 into SIGINT. Never send an SSH "signal" channel
|
||||
// request here — real OpenSSH rejects it ("session_signal_req: session
|
||||
// signalling requires privilege separation"), which pollutes auth logs and
|
||||
// makes other host-side tooling report brute-force noise (#3577).
|
||||
function sendInterrupt(trigger = null) {
|
||||
let signalSent = false;
|
||||
let etxWritten = false;
|
||||
try {
|
||||
if (typeof ptyStream.signal === "function") {
|
||||
ptyStream.signal("INT");
|
||||
signalSent = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore signal failures and fall back to ETX.
|
||||
}
|
||||
try {
|
||||
if (typeof ptyStream.write === "function") {
|
||||
ptyStream.write("\x03");
|
||||
@@ -275,7 +272,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
}
|
||||
jobDiag.emit("send-interrupt", {
|
||||
trigger,
|
||||
signalSent,
|
||||
signalSent: false,
|
||||
etxWritten,
|
||||
session: diagSnapshot(),
|
||||
});
|
||||
|
||||
@@ -153,6 +153,37 @@ test("foreground PTY cancellation returns only a bounded tail", async () => {
|
||||
assert.equal(result.outputTruncated, true);
|
||||
});
|
||||
|
||||
test("PTY cancellation delivers Ctrl+C as terminal data without a channel signal request", async () => {
|
||||
const calls = [];
|
||||
class SignalSpyPty extends EventEmitter {
|
||||
signal(name) {
|
||||
calls.push(["signal", name]);
|
||||
}
|
||||
write(data) {
|
||||
if (data === "\x03") calls.push(["write", data]);
|
||||
const marker = markerFromWrite(data);
|
||||
if (!marker || this.started) return;
|
||||
this.started = true;
|
||||
queueMicrotask(() => {
|
||||
this.emit("data", Buffer.from(`${marker}_S\n`));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pty = new SignalSpyPty();
|
||||
const job = startPtyJob(pty, "cancel-signal-request", {
|
||||
shellKind: "posix",
|
||||
timeoutMs: 1_000,
|
||||
expectedPrompt: "$ ",
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
job.cancel();
|
||||
pty.emit("data", Buffer.from("$ "));
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.error, "Cancelled");
|
||||
assert.deepEqual(calls, [["write", "\x03"]]);
|
||||
});
|
||||
|
||||
test("background PTY jobs preserve output that has no trailing newline", async () => {
|
||||
const job = startPtyJob(new ShellBackedPty(), "printf 'abc'", {
|
||||
shellKind: "posix",
|
||||
|
||||
@@ -352,7 +352,8 @@ function createMonitorService({
|
||||
historyId,
|
||||
status: entry?.status ?? host.status ?? "limit",
|
||||
at: shared?.at ?? 0,
|
||||
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
|
||||
statsAt: shared?.statsAt ?? shared?.at ?? 0,
|
||||
...(append && last?.historyId === historyId && last.statsAt === (shared?.statsAt ?? shared?.at ?? 0)
|
||||
? {}
|
||||
: { stats: shared?.result?.success ? shared.result.stats : null }),
|
||||
points: append && last?.historyId === historyId
|
||||
|
||||
@@ -280,7 +280,7 @@ test("full terminal readers upgrade a lightweight cached sample and keep subsequ
|
||||
memTotal: 100,
|
||||
memUsed: null,
|
||||
topProcesses: ctx._monitorStats ? [] : [{ name: "full-process" }],
|
||||
gpu: ctx._monitorStats ? null : 75,
|
||||
gpu: 75,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -752,3 +752,23 @@ test("snapshot deltas send new points only, even with 256 full retained historie
|
||||
assert.ok(Object.entries(fresh.hosts).every(([id, host]) => id === "h0" || !Object.hasOwn(host, "stats")));
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test('a completed GPU probe reaches incremental snapshots before the next CPU sample',async()=>{
|
||||
const {cacheFullGpuStats}=require('./sshBridge/monitorGpuStats.cjs');
|
||||
const f=fixture();
|
||||
try {
|
||||
f.service.reconcile(event(1),[{...demand(),interval:60}]);
|
||||
await until(()=>f.service.snapshot(event(1)).hosts.h?.status==='live');
|
||||
const initial=f.service.snapshot(event(1));
|
||||
const host=initial.hosts.h;
|
||||
const cursor={runtimeId:initial.runtimeId,epoch:initial.epoch,after:{h:{at:host.points.at(-1)?.at??0,statsAt:host.statsAt,historyId:host.historyId}}};
|
||||
cacheFullGpuStats(f.c,{gpu:75,gpuName:'NVIDIA independent',gpuMemUsed:512,gpuMemTotal:8192});
|
||||
const next=f.service.snapshot(event(1),cursor).hosts.h;
|
||||
assert.equal(next.stats.gpu,75,'GPU data must not be omitted by the primary sample cursor');
|
||||
assert.equal(next.at,host.at,'GPU publication must not change CPU freshness');
|
||||
assert.deepEqual(next.points,[],'GPU publication must not add trend samples');
|
||||
assert.ok(next.statsAt>host.statsAt);
|
||||
const caughtUp={...cursor,after:{h:{...cursor.after.h,statsAt:next.statsAt}}};
|
||||
assert.equal(f.service.snapshot(event(1),caughtUp).hosts.h.stats,undefined,'unchanged GPU data is not repeatedly sent');
|
||||
} finally {f.service.dispose();}
|
||||
});
|
||||
@@ -91,6 +91,54 @@ test("worker IPC installs one owner teardown across all monitor handlers", async
|
||||
]);
|
||||
});
|
||||
|
||||
test("starting monitoring publishes latency even when SSH keepalives are disabled", async () => {
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { selectServerStatsFixtureOutput } = require("./sshBridge/serverStatsTestHelpers.cjs");
|
||||
const handlers = register();
|
||||
const e = event(93);
|
||||
const options = {
|
||||
hostname: "latency-fixture",
|
||||
username: "fixture",
|
||||
password: "fixture-only",
|
||||
keepaliveInterval: 0,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
const conn = new EventEmitter();
|
||||
conn._sock = { destroyed: false };
|
||||
conn.config = { keepaliveInterval: 0 };
|
||||
conn.end = () => {};
|
||||
conn.exec = (command, callback) => {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.write = () => true;
|
||||
callback(null, stream);
|
||||
setImmediate(() => {
|
||||
stream.emit("data", Buffer.from(selectServerStatsFixtureOutput(command,
|
||||
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:")));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
};
|
||||
const transport = pool.createTransport({ conn, endpoint: pool.buildConnectionReuseEndpoint(options) });
|
||||
const holder = {};
|
||||
pool.borrowTransport(transport, { kind: pool.LEASE_KINDS.shell, holder });
|
||||
try {
|
||||
await handlers.get("netcatty:monitor:update")(e, [{ id: "latency", hosts: [{ id: "host", options }] }]);
|
||||
let snapshot;
|
||||
const deadline = Date.now() + 2000;
|
||||
do {
|
||||
await tick();
|
||||
snapshot = handlers.get("netcatty:monitor:snapshot")(e);
|
||||
} while (snapshot.hosts.host?.status !== "live" && Date.now() < deadline);
|
||||
assert.equal(snapshot.hosts.host?.status, "live");
|
||||
assert.equal(snapshot.hosts.host.stats.memTotal, 8000);
|
||||
assert.equal(snapshot.hosts.host.stats.latencyMs, null, "keepalive opt-out is preserved");
|
||||
assert.equal(typeof snapshot.hosts.host.stats.responseLatencyMs, "number");
|
||||
} finally {
|
||||
bridge.stopAllMonitoring();
|
||||
pool.returnTransport(holder);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("worker receiving handlers accept the actual IPC sender without WebContents lifecycle methods", async () => {
|
||||
const { createWorkerSender } = require("../terminalWorker/process.cjs");
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
|
||||
const GPU_ASSIGNMENT = `gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`;
|
||||
const EMPTY_GPU = {
|
||||
gpu: null,
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
};
|
||||
const states = new WeakMap();
|
||||
function stateFor(conn) {
|
||||
let state = states.get(conn);
|
||||
if (!state) {
|
||||
state = {
|
||||
stats: EMPTY_GPU,
|
||||
nextAt: 0,
|
||||
pending: null,
|
||||
controller: null,
|
||||
updatedAt: 0,
|
||||
disabled: false,
|
||||
};
|
||||
states.set(conn, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
function updateGpuStats(state, next) {
|
||||
if (Object.keys(EMPTY_GPU).some((key) => state.stats[key] !== next[key])) {
|
||||
// Publication time is separate from CPU history and strictly advances even
|
||||
// when a fast GPU reply shares the CPU sample's millisecond.
|
||||
state.updatedAt = Math.max(Date.now() + 1, state.updatedAt + 1);
|
||||
}
|
||||
state.stats = next;
|
||||
}
|
||||
function monitorGpuUpdatedAt(conn) {
|
||||
return states.get(conn)?.updatedAt ?? 0;
|
||||
}
|
||||
function parseGpuStats(output) {
|
||||
const raw =
|
||||
String(output || "")
|
||||
.split("|")
|
||||
.find((part) => part.startsWith("GPU:"))
|
||||
?.slice(4)
|
||||
.trim()
|
||||
.split(/\s+/) ?? [];
|
||||
const utilization = parseInt(raw[0], 10);
|
||||
if (!Number.isFinite(utilization)) return EMPTY_GPU;
|
||||
const number = (value) =>
|
||||
Number.isFinite(parseInt(value, 10)) ? parseInt(value, 10) : null;
|
||||
return {
|
||||
gpu: Math.max(0, Math.min(100, utilization)),
|
||||
gpuName: raw.slice(3).join(" ") || null,
|
||||
gpuMemUsed: number(raw[1]),
|
||||
gpuMemTotal: number(raw[2]),
|
||||
};
|
||||
}
|
||||
function withCachedMonitorGpu(conn, result) {
|
||||
const state = states.get(conn);
|
||||
return state && result?.success
|
||||
? { ...result, stats: { ...result.stats, ...state.stats } }
|
||||
: result;
|
||||
}
|
||||
function cacheFullGpuStats(conn, stats) {
|
||||
const state = stateFor(conn);
|
||||
state.controller?.abort();
|
||||
updateGpuStats(
|
||||
state,
|
||||
Object.fromEntries(
|
||||
Object.keys(EMPTY_GPU).map((key) => [key, stats[key] ?? null]),
|
||||
),
|
||||
);
|
||||
state.nextAt = Date.now() + 10000;
|
||||
}
|
||||
/** Optional GPU work never joins the CPU/memory sampler or its history clock. */
|
||||
function startMonitorGpuProbe(conn, command, options = {}) {
|
||||
const state = stateFor(conn);
|
||||
if (state.disabled || state.pending || Date.now() < state.nextAt) return;
|
||||
const controller = new AbortController();
|
||||
state.controller = controller;
|
||||
state.nextAt = Date.now() + 30000;
|
||||
state.pending = Promise.resolve()
|
||||
.then(() =>
|
||||
executeBoundedSshCommand(conn, command, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
invalidateTransportOnAbort: false,
|
||||
invalidateOnOpenTimeout: false,
|
||||
openingTimeoutMs: 2500,
|
||||
runTimeoutMs: 2500,
|
||||
maxOutputBytes: 65536,
|
||||
}),
|
||||
)
|
||||
.then(({ stdout }) => {
|
||||
if (controller.signal.aborted) return;
|
||||
updateGpuStats(state, parseGpuStats(stdout));
|
||||
state.nextAt = Date.now() + (state.stats.gpu == null ? 30000 : 10000);
|
||||
})
|
||||
.catch((error) => {
|
||||
// A timed-out channel open cannot be withdrawn from ssh2's request FIFO.
|
||||
// Keep this optional probe from disconnecting SSH or piling up requests.
|
||||
if (error?.code === "SSH_EXEC_OPEN_TIMEOUT") state.disabled = true;
|
||||
if (!controller.signal.aborted) updateGpuStats(state, EMPTY_GPU);
|
||||
})
|
||||
.finally(() => {
|
||||
state.pending = null;
|
||||
if (state.controller === controller) state.controller = null;
|
||||
});
|
||||
}
|
||||
function cancelMonitorGpuProbe(conn) {
|
||||
states.get(conn)?.controller?.abort();
|
||||
}
|
||||
module.exports = {
|
||||
GPU_ASSIGNMENT,
|
||||
monitorGpuUpdatedAt,
|
||||
parseGpuStats,
|
||||
withCachedMonitorGpu,
|
||||
cacheFullGpuStats,
|
||||
startMonitorGpuProbe,
|
||||
cancelMonitorGpuProbe,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
startMonitorGpuProbe,
|
||||
withCachedMonitorGpu,
|
||||
cacheFullGpuStats,
|
||||
cancelMonitorGpuProbe,
|
||||
} = require("./monitorGpuStats.cjs");
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function stream() {
|
||||
const s = new EventEmitter();
|
||||
s.stderr = new EventEmitter();
|
||||
s.close = () => {};
|
||||
s.destroy = () => {};
|
||||
return s;
|
||||
}
|
||||
const sample = { success: true, stats: { cpu: 12, memTotal: 4096, gpu: null } };
|
||||
test("GPU work is coalesced, cached independently, and never changes CPU readings", async (t) => {
|
||||
let now = 100000,
|
||||
calls = 0,
|
||||
current;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
calls++;
|
||||
current = stream();
|
||||
cb(null, current);
|
||||
},
|
||||
};
|
||||
t.after(() => cancelMonitorGpuProbe(conn));
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
|
||||
current.emit("data", Buffer.from("GPU:75 1024 8192 NVIDIA T4"));
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 75);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpuName, "NVIDIA T4");
|
||||
now += 9999;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
now += 1;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 2);
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
|
||||
now += 29000;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 2, "missing GPU probes back off");
|
||||
});
|
||||
test("cancelling optional GPU work preserves a full reader cache and ignores late results", async (t) => {
|
||||
let current;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
current = stream();
|
||||
cb(null, current);
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
cacheFullGpuStats(conn, {
|
||||
gpu: 42,
|
||||
gpuName: "NVIDIA full",
|
||||
gpuMemUsed: 512,
|
||||
gpuMemTotal: 8192,
|
||||
});
|
||||
current.emit("data", Buffer.from("GPU:99 1 2 NVIDIA late"));
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 42);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
|
||||
cancelMonitorGpuProbe(conn);
|
||||
t.after(() => cancelMonitorGpuProbe(conn));
|
||||
});
|
||||
|
||||
test("monitor snapshots include cached GPU without adding history samples or changing sample time", async () => {
|
||||
const { sampleSharedStats, readSharedStats } = require("./sharedStats.cjs");
|
||||
const conn = {};
|
||||
await sampleSharedStats({ conn, _monitorStats: true }, async () => sample);
|
||||
const before = readSharedStats(conn);
|
||||
cacheFullGpuStats(conn, {
|
||||
gpu: 42,
|
||||
gpuName: "NVIDIA cached",
|
||||
gpuMemUsed: 1,
|
||||
gpuMemTotal: 2,
|
||||
});
|
||||
const after = readSharedStats(conn);
|
||||
assert.equal(after.result.stats.gpu, 42);
|
||||
assert.equal(after.result.stats.cpu, 12);
|
||||
assert.equal(after.at, before.at);
|
||||
assert.deepEqual(after.points, before.points);
|
||||
});
|
||||
test("stopping GPU work before channel-open closes the late channel without disconnecting SSH", async () => {
|
||||
let callback,
|
||||
closed = 0,
|
||||
disconnected = 0;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
callback = cb;
|
||||
},
|
||||
end() {
|
||||
disconnected++;
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
require("./sharedStats.cjs").cancelSharedStats(conn);
|
||||
const late = stream();
|
||||
late.close = () => closed++;
|
||||
callback(null, late);
|
||||
await tick();
|
||||
assert.ok(closed > 0);
|
||||
assert.equal(disconnected, 0);
|
||||
});
|
||||
|
||||
test("optional GPU channel-open timeout neither disconnects SSH nor stacks repeated open requests", async () => {
|
||||
let callback,
|
||||
fireTimer,
|
||||
calls = 0,
|
||||
disconnected = 0,
|
||||
closed = 0;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
calls++;
|
||||
callback = cb;
|
||||
},
|
||||
end() {
|
||||
disconnected++;
|
||||
},
|
||||
destroy() {
|
||||
disconnected++;
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu", {
|
||||
setTimeoutFn: (fn) => {
|
||||
fireTimer = fn;
|
||||
return 1;
|
||||
},
|
||||
clearTimeoutFn: () => {},
|
||||
});
|
||||
await tick();
|
||||
fireTimer();
|
||||
await tick();
|
||||
assert.equal(
|
||||
disconnected,
|
||||
0,
|
||||
"optional channel timeout must leave the shared transport intact",
|
||||
);
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1, "one unresolved GPU open per transport at most");
|
||||
const late = stream();
|
||||
late.close = () => closed++;
|
||||
callback(null, late);
|
||||
await tick();
|
||||
assert.ok(closed > 0);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { sampleSharedStats } = require("./sharedStats.cjs");
|
||||
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
||||
function extraExecUnsupportedError(session) {
|
||||
@@ -1066,7 +1067,18 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
|
||||
async function getStatsForContext(session, event, sessionId) {
|
||||
return sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
|
||||
const result = await sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
|
||||
const conn = session.conn || session.moshStatsConn || session.etStatsConn;
|
||||
if (!conn || !result.success || !result.stats) return result;
|
||||
if (!session._monitorStats) {
|
||||
cacheFullGpuStats(conn, result.stats);
|
||||
return result;
|
||||
}
|
||||
if (!session.singleChannelSsh) {
|
||||
const command = withPosixRemoteWatchdog(`if [ "$(uname -s 2>/dev/null)" = "Linux" ]; then ${GPU_ASSIGNMENT}; echo "GPU:$gpustat"; fi`, 2500);
|
||||
startMonitorGpuProbe(conn, command, {setTimeoutFn:setTimeout,clearTimeoutFn:clearTimeout});
|
||||
}
|
||||
return withCachedMonitorGpu(conn, result);
|
||||
}
|
||||
|
||||
async function collectServerStats(session, event, sessionId) {
|
||||
@@ -1199,7 +1211,7 @@ function createSessionOpsApi(ctx) {
|
||||
`uptime=$(awk -v n="$nowsec" -v b="$bootsec" 'BEGIN{if(n>0&&b>0)printf "%.0f",n-b}')`,
|
||||
`loadavg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1" "$2" "$3"}')`,
|
||||
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`,
|
||||
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
|
||||
].filter(command => !session._monitorStats || !command.startsWith('procs=')).join('; ');
|
||||
|
||||
// Prefer df's filesystem type column so FUSE mounts can be identified
|
||||
// even when their source name looks like an ordinary remote path.
|
||||
@@ -1302,7 +1314,7 @@ function createSessionOpsApi(ctx) {
|
||||
// Bound GPU queries independently so a stuck driver cannot stop CPU/memory
|
||||
// polling. Hosts without timeout also omit this optional metric.
|
||||
// macOS is skipped: powermetrics needs root, which stats must not.
|
||||
`gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`,
|
||||
GPU_ASSIGNMENT,
|
||||
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
|
||||
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
|
||||
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
|
||||
@@ -1364,6 +1376,12 @@ function createSessionOpsApi(ctx) {
|
||||
const pingLatencyPromise = sshStatsConn && !pingDisabled && typeof measureSshPingLatency === 'function'
|
||||
? Promise.resolve(measureSshPingLatency(sshStatsConn)).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
// Observe the exec acknowledgement already needed for this sample. This
|
||||
// remains available when global-request keepalives are disabled, without
|
||||
// sending another ping or opening an additional channel/connection.
|
||||
// Keep it separate from the transport RTT: channel setup also includes
|
||||
// remote processing, and shared terminal readers must retain that distinction.
|
||||
let responseLatencyMs = null;
|
||||
const formatStatsError = (error) => (
|
||||
error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
|
||||
? "Timeout getting server stats"
|
||||
@@ -1379,7 +1397,13 @@ function createSessionOpsApi(ctx) {
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const { stdout } = await executeStatsCommand(statsCommand);
|
||||
const responseStartedAt = performance.now();
|
||||
const { stdout } = await executeStatsCommand(statsCommand, {
|
||||
...statsExecOptions,
|
||||
onStream: () => {
|
||||
if (sshStatsConn) responseLatencyMs = Math.max(0, Math.round(performance.now() - responseStartedAt));
|
||||
},
|
||||
});
|
||||
if (settled) return;
|
||||
let combinedStdout = String(stdout || '').trim();
|
||||
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
|
||||
@@ -1555,27 +1579,7 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
}
|
||||
} else if (part.startsWith('GPU:')) {
|
||||
// Linux: "<avgUtil> <sumMemUsed> <sumMemTotal> <first GPU name>"
|
||||
const gpuStr = part.substring(4).trim();
|
||||
if (gpuStr && gpuStr !== '') {
|
||||
const gpuFields = gpuStr.split(/\s+/);
|
||||
const util = parseInt(gpuFields[0], 10);
|
||||
if (!isNaN(util)) {
|
||||
gpu = Math.min(100, Math.max(0, util));
|
||||
if (gpuFields.length >= 2) {
|
||||
const memUsed = parseInt(gpuFields[1], 10);
|
||||
if (!isNaN(memUsed)) gpuMemUsed = memUsed;
|
||||
}
|
||||
if (gpuFields.length >= 3) {
|
||||
const memTotal = parseInt(gpuFields[2], 10);
|
||||
if (!isNaN(memTotal)) gpuMemTotal = memTotal;
|
||||
}
|
||||
if (gpuFields.length >= 4) {
|
||||
const name = gpuFields.slice(3).join(' ').trim();
|
||||
if (name) gpuName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
({gpu,gpuName,gpuMemUsed,gpuMemTotal}=parseGpuStats(part));
|
||||
} else if (part.startsWith('HOST:')) {
|
||||
hostname = part.substring(5).trim();
|
||||
} else if (part.startsWith('OS:')) {
|
||||
@@ -1744,6 +1748,7 @@ function createSessionOpsApi(ctx) {
|
||||
netRxSpeed, // Total network receive speed (bytes/sec)
|
||||
netTxSpeed, // Total network transmit speed (bytes/sec)
|
||||
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
|
||||
responseLatencyMs, // Existing stats exec acknowledgement (ms), including remote channel setup
|
||||
netInterfaces, // Per-interface network stats
|
||||
hostname,
|
||||
osName,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { mkdtempSync, writeFileSync, rmSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
@@ -682,7 +682,8 @@ for (const scenario of [
|
||||
{ name: "missing timeout tool", script: "exec sleep 20", gpu: null, noTimeout: true },
|
||||
{ name: "blocked GPU query", script: "trap '' TERM; exec sleep 20", gpu: null },
|
||||
]) {
|
||||
test(`real stats shell preserves other metrics with ${scenario.name}`, async (t) => {
|
||||
for (const monitorOnly of [false, true]) {
|
||||
test(`real stats shell preserves other metrics with ${scenario.name} (${monitorOnly ? "monitor-only" : "terminal"})`, async (t) => {
|
||||
if (spawnSync("timeout", ["-s", "KILL", "2", "true"]).status !== 0) {
|
||||
t.skip("GNU/BusyBox-compatible timeout is unavailable on this test host");
|
||||
return;
|
||||
@@ -690,11 +691,17 @@ for (const scenario of [
|
||||
const dir = mkdtempSync(getTempFilePath("gpu-stats-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
writeFileSync(join(dir, "nvidia-smi"), `#!/bin/sh\n${scenario.script}\n`, { mode: 0o755 });
|
||||
let finishGpu;
|
||||
const gpuDone = new Promise(resolve => {finishGpu = resolve;});
|
||||
let gpuCalls = 0;
|
||||
const sessions = new Map([["sid", {
|
||||
type: "ssh",
|
||||
_monitorStats: monitorOnly,
|
||||
conn: {
|
||||
exec(command, cb) {
|
||||
const execution = spawnSync("sh", ["-c", [
|
||||
const gpuOnly = !command.includes("NC_LATENCY_MARK") && command.includes("nvidia-smi");
|
||||
if(gpuOnly) gpuCalls++;
|
||||
const child = spawn("sh", ["-c", [
|
||||
"uname() { printf '%s\\n' Linux; }",
|
||||
"nproc() { printf '%s\\n' 4; }",
|
||||
"top() { return 1; }",
|
||||
@@ -703,22 +710,39 @@ for (const scenario of [
|
||||
? unwrapExecShC(command).replace("timeout -s KILL 2 nvidia-smi", "nc_missing_timeout -s KILL 2 nvidia-smi")
|
||||
: unwrapExecShC(command),
|
||||
].join("\n")], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${dir}:${process.env.PATH}` },
|
||||
timeout: 7000,
|
||||
});
|
||||
assert.equal(execution.status, 0, execution.stderr);
|
||||
cb(null, fakeStream(execution.stdout));
|
||||
let stdout="", stderr="";
|
||||
child.stdout.on("data", data => {stdout+=data;});
|
||||
child.stderr.on("data", data => {stderr+=data;});
|
||||
child.on("error", cb);
|
||||
child.on("close", code => {
|
||||
assert.equal(code,0,stderr);
|
||||
cb(null,fakeStream(stdout));
|
||||
if(gpuOnly)finishGpu();
|
||||
});
|
||||
},
|
||||
},
|
||||
}]]);
|
||||
const result = await makeSessionOps(sessions).getServerStats({ sender: {} }, { sessionId: "sid" });
|
||||
const api=makeSessionOps(sessions);
|
||||
const sampleStarted=Date.now();
|
||||
let result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||||
if(monitorOnly){
|
||||
if(scenario.name === "blocked GPU query")assert.ok(Date.now()-sampleStarted<1500,"a hung GPU must not add its two-second timeout to CPU samples");
|
||||
// Main readings arrive independently, before the optional GPU query ends.
|
||||
assert.equal(result.stats.gpu,null);
|
||||
await gpuDone;
|
||||
await new Promise(resolve=>setImmediate(()=>setImmediate(resolve)));
|
||||
result=await api.getServerStats({sender:{}},{sessionId:"sid"});
|
||||
assert.equal(gpuCalls,1,"cached GPU fields must not open another probe");
|
||||
}
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.cpuCores, 4);
|
||||
assert.equal(result.stats.gpu, scenario.gpu);
|
||||
assert.equal(result.stats.gpuMemUsed, scenario.used ?? null);
|
||||
assert.equal(result.stats.gpuMemTotal, scenario.total ?? null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("getServerStats measures latency by pinging the stats connection", async () => {
|
||||
@@ -1406,6 +1430,7 @@ for (const keepaliveInterval of [0, 10000]) {
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(pingCalls, keepaliveInterval === 0 ? 0 : 1);
|
||||
assert.equal(result.stats.latencyMs, keepaliveInterval === 0 ? null : 2);
|
||||
assert.equal(Number.isFinite(result.stats.responseLatencyMs), true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1449,3 +1474,25 @@ test("real macOS stats shell parses the root disk without broken awk escaping",
|
||||
assert.equal(result.stats.disks[0].capacityKey, "apfs:/dev/disk3");
|
||||
assert.equal(result.stats.diskPercent, 50);
|
||||
});
|
||||
|
||||
test("monitor CPU samples return while an optional GPU driver is still blocked", async () => {
|
||||
let finishGpu, gpuCalls = 0, result;
|
||||
const c = {exec(command, callback) {
|
||||
if (command.includes('nvidia-smi')) {
|
||||
gpuCalls++;
|
||||
finishGpu = () => callback(null, fakeStream('GPU:50 1024 8192 NVIDIA test'));
|
||||
} else callback(null, fakeStream(selectServerStatsFixtureOutput(command, LINUX_STATS)));
|
||||
}};
|
||||
const api = makeSessionOps(new Map());
|
||||
const pending=api.getStatsForContext({conn:c,_monitorStats:true,_monitorIntervalMs:1}).then(value=>{result=value;});
|
||||
try {
|
||||
for(let i=0;i<10;i++)await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.ok(result?.success, 'CPU and memory must not wait for the GPU probe');
|
||||
assert.equal(result.stats.memTotal,8000);
|
||||
assert.equal(gpuCalls,1);
|
||||
} finally {
|
||||
finishGpu?.();
|
||||
require("./sharedStats.cjs").cancelSharedStats(c);
|
||||
await pending;
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use strict";
|
||||
// One in-memory sampler per physical authenticated transport. No persistence/logging.
|
||||
const { withCachedMonitorGpu, cancelMonitorGpuProbe, monitorGpuUpdatedAt } = require("./monitorGpuStats.cjs");
|
||||
const { buildEndpointKey } = require("../sshConnectionPool.cjs");
|
||||
let states = new WeakMap();
|
||||
const identities = new Map();
|
||||
@@ -100,7 +101,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
return result;
|
||||
}
|
||||
// A terminal needs process/GPU fields omitted from a monitor-only query.
|
||||
// A terminal needs process and GPU fields omitted from a monitor-only query.
|
||||
// Finish the shared lightweight query, then upgrade once for all full readers.
|
||||
await state.pending;
|
||||
return sampleSharedStats(session, collect);
|
||||
@@ -156,9 +157,10 @@ function readSharedStats(conn) {
|
||||
const state = stateFor(conn);
|
||||
if (state.points[0] && Date.now() - state.points[0].at > 30 * 60000)
|
||||
state.points = state.points.filter((p) => Date.now() - p.at <= 30 * 60000);
|
||||
return { result: state.latest, at: state.at, points: state.points };
|
||||
return { result: state.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
|
||||
}
|
||||
function cancelSharedStats(conn) {
|
||||
cancelMonitorGpuProbe(conn);
|
||||
const state = states.get(conn);
|
||||
if (state && !state.terminalJoined) state.controller?.abort();
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ for (const keepaliveInterval of [10000, 0]) {
|
||||
assert.equal(forwardPort, 43210);
|
||||
if (keepaliveInterval > 0) assert.equal(Number.isFinite(stats.stats.latencyMs), true);
|
||||
else assert.equal(stats.stats.latencyMs, null);
|
||||
assert.equal(Number.isFinite(stats.stats.responseLatencyMs), true);
|
||||
await new Promise((resolve, reject) => conn.unforwardIn("127.0.0.1", forwardPort,
|
||||
(err) => err ? reject(err) : resolve()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Reserve the name synchronously so simultaneous downloads cannot overwrite
|
||||
// each other or a file already present in the user's Downloads directory.
|
||||
function reserveDownloadFile(directory, suggestedName) {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const basename = path.basename(String(suggestedName || "download").replace(/\\/g, "/"));
|
||||
const filename = !basename || basename === "." || basename === ".." ? "download" : basename;
|
||||
const extension = path.extname(filename);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
for (let suffix = 0; ; suffix++) {
|
||||
const savePath = path.join(directory, suffix ? `${stem} (${suffix})${extension}` : filename);
|
||||
let descriptor;
|
||||
try { descriptor = fs.openSync(savePath, "wx", 0o600); }
|
||||
catch (error) { if (error.code === "EEXIST") continue; throw error; }
|
||||
const reserved = fs.fstatSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
return {
|
||||
savePath,
|
||||
cleanupEmpty() {
|
||||
try {
|
||||
const current = fs.lstatSync(savePath);
|
||||
if (current.isFile() && current.size === 0 && current.ino === reserved.ino && current.dev === reserved.dev) fs.unlinkSync(savePath);
|
||||
} catch { /* Chromium may already have removed a cancelled download. */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { reserveDownloadFile };
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { createDownloadStore } = require("./downloadStore.cjs");
|
||||
const { reserveDownloadFile } = require("./downloadFile.cjs");
|
||||
const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
|
||||
|
||||
test("download history stays local to its host and survives restart", () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "host-browser-downloads-"));
|
||||
const directory = fs.mkdtempSync(getTempFilePath("host-browser-downloads-"));
|
||||
try {
|
||||
const app = { getPath: () => directory };
|
||||
const file = path.join(directory, "download.bin");
|
||||
@@ -28,3 +29,32 @@ test("download history stays local to its host and survives restart", () => {
|
||||
assert.equal(restored.list("host-b").length, 1);
|
||||
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("automatic downloads reserve separate names without replacing existing files", (t) => {
|
||||
const directory = fs.mkdtempSync(getTempFilePath("host-browser-download-names-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
fs.writeFileSync(path.join(directory, "report.txt"), "existing file");
|
||||
fs.mkdirSync(path.join(directory, "report (1).txt"));
|
||||
const first = reserveDownloadFile(directory, "report.txt");
|
||||
const second = reserveDownloadFile(directory, "report.txt");
|
||||
assert.equal(path.basename(first.savePath), "report (2).txt");
|
||||
assert.equal(path.basename(second.savePath), "report (3).txt");
|
||||
assert.equal(fs.readFileSync(path.join(directory, "report.txt"), "utf8"), "existing file");
|
||||
first.cleanupEmpty();
|
||||
assert.equal(fs.existsSync(first.savePath), false);
|
||||
fs.writeFileSync(second.savePath, "downloaded content");
|
||||
second.cleanupEmpty();
|
||||
assert.equal(fs.readFileSync(second.savePath, "utf8"), "downloaded content");
|
||||
});
|
||||
|
||||
test("cleanup removes only its own empty reservation", (t) => {
|
||||
const directory = fs.mkdtempSync(getTempFilePath("host-browser-download-cleanup-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const reserved = reserveDownloadFile(directory, "../report.txt");
|
||||
assert.equal(reserved.savePath, path.join(directory, "report.txt"));
|
||||
const otherFile = path.join(directory, "replacement");
|
||||
fs.writeFileSync(otherFile, "");
|
||||
fs.renameSync(otherFile, reserved.savePath);
|
||||
reserved.cleanupEmpty();
|
||||
assert.equal(fs.existsSync(reserved.savePath), true, "a replacement made by someone else is left alone");
|
||||
});
|
||||
@@ -39,7 +39,7 @@ function size(bytes) {
|
||||
|
||||
function statusText(item) {
|
||||
if (item.missing) return "File moved or deleted";
|
||||
if (item.status === "waiting") return "Choose where to save\u2026";
|
||||
if (item.status === "waiting") return "Preparing download\u2026";
|
||||
if (item.status === "progressing") return item.total
|
||||
? `${size(item.received)} of ${size(item.total)} \u00b7 ${Math.min(100, Math.round(item.received * 100 / item.total))}%`
|
||||
: `${size(item.received)} \u00b7 Downloading\u2026`;
|
||||
@@ -112,6 +112,7 @@ function render(downloads) {
|
||||
actionMenu.append(choice);
|
||||
};
|
||||
if (item.status === "completed" && !item.missing) addMenuAction("Open file", "open");
|
||||
if (item.status === "completed" && !item.missing) addMenuAction("Save as\u2026", "save-as");
|
||||
if (!["waiting", "progressing"].includes(item.status)) addMenuAction("Remove from list", "remove");
|
||||
if (actionMenu.childElementCount) actions.append(more);
|
||||
row.append(file, detail, actions, actionMenu);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Browser renderer acceptance</title></head><body><div id="fixture"></div><script type="module" src="./renderer.tsx"></script></body></html>
|
||||
@@ -0,0 +1,35 @@
|
||||
// Real renderer controls with isolated desktop services, used by the Electron acceptance test.
|
||||
import React, { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { HostBrowserSidePanel } from '../../../components/HostBrowserSidePanel';
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../components/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/popover';
|
||||
import type { TerminalSettings } from '../../../domain/models';
|
||||
import '../../../index.css';
|
||||
|
||||
for (const [name, value] of Object.entries({ background: '210 25% 18%', foreground: '210 20% 90%', secondary: '210 20% 24%', muted: '210 20% 22%', 'muted-foreground': '210 15% 65%', border: '210 20% 30%', primary: '170 70% 42%', 'primary-foreground': '210 20% 12%', popover: '210 25% 20%' })) document.documentElement.style.setProperty(`--${name}`, value);
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.setProperty('--radius', '8px');
|
||||
document.documentElement.style.setProperty('--ring', '170 70% 42%');
|
||||
document.documentElement.style.setProperty('--input', '210 20% 30%');
|
||||
document.documentElement.style.setProperty('--primary-foreground', '210 20% 12%');
|
||||
document.documentElement.style.setProperty('--destructive', '0 70% 60%');
|
||||
|
||||
function Fixture() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [locale, setLocale] = useState('zh-CN');
|
||||
return <I18nProvider locale={locale}><TooltipProvider delayDuration={100}>
|
||||
<div className="flex h-screen flex-col bg-background p-3 text-foreground">
|
||||
<div className="flex h-10 shrink-0 items-center gap-3">
|
||||
{['上下分屏', '左右分屏'].map((label, index) => <Popover key={label}><Tooltip><TooltipTrigger asChild><PopoverTrigger asChild><button id={`split-${index}`} className="rounded px-2 py-1 text-xs hover:bg-secondary">{label}</button></PopoverTrigger></TooltipTrigger><TooltipContent side="bottom">{label}</TooltipContent></Tooltip><PopoverContent align="start" className="w-48 p-1">{Array.from({ length: 8 }, (_, item) => <button key={item} className="block w-full rounded px-2 py-2 text-left text-xs hover:bg-secondary" id={`choice-${index}-${item}`} onClick={(event) => { event.currentTarget.dataset.chosen = 'true'; }}>Panel {item + 1}</button>)}</PopoverContent></Popover>)}
|
||||
<button id="visibility" className="text-xs" onClick={() => setVisible((value) => !value)}>Toggle panel</button>
|
||||
<select aria-label="Locale" value={locale} onChange={(event) => setLocale(event.target.value)}>{['zh-CN', 'en', 'zh-TW', 'es', 'ru'].map((value) => <option key={value}>{value}</option>)}</select>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border/50">
|
||||
<HostBrowserSidePanel browserId="local:renderer-acceptance" host={{ id: 'local', protocol: 'local', label: 'Local browser', hostname: '', username: '', tags: [], os: 'macos' }} hosts={[]} keys={[]} identities={[]} knownHosts={[]} terminalSettings={{} as TerminalSettings} isVisible={visible} />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider></I18nProvider>;
|
||||
}
|
||||
createRoot(document.getElementById('fixture')!).render(<React.StrictMode><Fixture /></React.StrictMode>);
|
||||
@@ -62,7 +62,7 @@ async function main() {
|
||||
const globals = { ...React, Map, Promise, Object, crypto: require("node:crypto").webcrypto,
|
||||
setTimeout, clearTimeout, queueMicrotask,
|
||||
requestAnimationFrame: () => 1, cancelAnimationFrame: () => {},
|
||||
document: { querySelector: () => null, documentElement: { classList: { contains: () => false } } },
|
||||
document: { querySelector: () => null, querySelectorAll: () => [], documentElement: { classList: { contains: () => false } } },
|
||||
getComputedStyle: () => style, netcattyBridge: { get: () => bridge }, isValidHslToken: () => false,
|
||||
};
|
||||
const { mountHostBrowser, currentBrowserThemeTokens } = loadModule("application/hostBrowser.ts", globals);
|
||||
@@ -83,19 +83,19 @@ async function main() {
|
||||
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Panel)), { createNodeMock: () => element });
|
||||
});
|
||||
await Promise.all(opens);
|
||||
const root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
|
||||
&& child.webContents.getURL().endsWith("browser.html")));
|
||||
assert.ok(root, "the first click must open the browser pane");
|
||||
const chrome = root.children[0].webContents;
|
||||
const snapshot = await chrome.executeJavaScript("window.hostBrowser.snapshot()");
|
||||
assert.equal(snapshot.tabs[0].url, url, "the first click must also open its page without a second terminal click");
|
||||
const snapshot = manager.getPanelState(event, browserId);
|
||||
assert.equal(snapshot.tabs[0].url, url, "the first click must open its page without a second terminal click");
|
||||
let loadedTitle = "";
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
loadedTitle = (await chrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title;
|
||||
loadedTitle = manager.getPanelState(event, browserId).tabs[0].title;
|
||||
if (loadedTitle === "First click opened") break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.equal(loadedTitle, "First click opened", "the first-click page must actually finish loading");
|
||||
const root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
|
||||
&& child.webContents.getURL() === url));
|
||||
assert.ok(root, "the first click must open the browser pane");
|
||||
assert.equal(root.children.length, 1, "only the website has a native view; controls belong to the app renderer");
|
||||
const page = root.children.find((view) => view instanceof WebContentsView && view.webContents.getURL() === url).webContents;
|
||||
assert.equal(page.getWebRTCIPHandlingPolicy(), 'disable_non_proxied_udp');
|
||||
page.setWebRTCIPHandlingPolicy('default');
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Run with: electron electron/hostBrowser/hostBrowser.live.test.cjs
|
||||
const assert = require("node:assert/strict");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { app, BrowserWindow, WebContentsView, session, ipcMain } = require("electron");
|
||||
@@ -77,7 +77,10 @@ function makeSocksServer() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
app.setPath("userData", path.join(os.tmpdir(), `netcatty-host-browser-live-${process.pid}`));
|
||||
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-standalone-"));
|
||||
app.setPath("userData", profile);
|
||||
fs.mkdirSync(path.join(profile, "downloads"));
|
||||
app.setPath("downloads", path.join(profile, "downloads"));
|
||||
const socks = makeSocksServer();
|
||||
await new Promise((resolve) => socks.listen(0, "127.0.0.1", resolve));
|
||||
const port = socks.address().port;
|
||||
@@ -137,7 +140,7 @@ async function main() {
|
||||
await page.executeJavaScript("document.querySelector('[data-icon-mode=text]').click();document.querySelector('[data-scope=all-hosts]').click()");
|
||||
assert.equal(await page.executeJavaScript("document.getElementById('bookmark-scope').value"), "all-hosts");
|
||||
await page.executeJavaScript("document.querySelector('[data-scope=host]').click()");
|
||||
const formScreenshot = path.join(os.tmpdir(), "netcatty-host-browser-app-form.png");
|
||||
const formScreenshot = path.join(profile, "netcatty-host-browser-app-form.png");
|
||||
fs.writeFileSync(formScreenshot, (await page.capturePage()).toPNG());
|
||||
await page.executeJavaScript("document.getElementById('bookmark-back').click()");
|
||||
await page.executeJavaScript("document.querySelector('.app-tile').click()");
|
||||
@@ -155,11 +158,9 @@ async function main() {
|
||||
const downloadsView = browser.contentView.children[1];
|
||||
assert.equal(await downloadsView.webContents.executeJavaScript("document.getElementById('empty').hidden"), false);
|
||||
assert.ok(downloadsView.getBounds().height < 300);
|
||||
const downloadsScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloads.png"));
|
||||
const downloadPath = path.join(app.getPath("userData"), "host-browser-test.bin");
|
||||
const slowPath = path.join(app.getPath("userData"), "slow.bin");
|
||||
const downloadsScreenshot = await captureView(downloadsView, path.join(profile, "netcatty-host-browser-downloads.png"));
|
||||
const downloadPath = path.join(app.getPath("downloads"), "host-browser-test.bin");
|
||||
const websiteView = browser.contentView.children[0];
|
||||
websiteView.webContents.session.on("will-download", (_event, item) => item.setSavePath(item.getFilename() === "slow.bin" ? slowPath : downloadPath));
|
||||
browser.contentView.children[0].webContents.downloadURL("http://example.invalid:3000/download.bin");
|
||||
for (let i = 0; i < 50 && !(await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.status === "completed"); i += 1) await wait(100);
|
||||
const downloaded = (await page.executeJavaScript("window.hostBrowser.listDownloads()"))[0];
|
||||
@@ -182,7 +183,7 @@ async function main() {
|
||||
await downloadsView.webContents.executeJavaScript("document.querySelector('button[title=\"Cancel download\"]').click()");
|
||||
for (let i = 0; i < 40 && !(await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.filename === "slow.bin" && item.status === "cancelled"); i += 1) await wait(100);
|
||||
assert.equal((await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.filename === "slow.bin" && item.status === "cancelled"), true);
|
||||
const downloadedScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloaded.png"));
|
||||
const downloadedScreenshot = await captureView(downloadsView, path.join(profile, "netcatty-host-browser-downloaded.png"));
|
||||
assert.equal(downloaded.missing, false);
|
||||
await downloadsView.webContents.executeJavaScript("document.getElementById('clear').click()");
|
||||
for (let i = 0; i < 20 && (await page.executeJavaScript("window.hostBrowser.listDownloads()")).length; i += 1) await wait(100);
|
||||
@@ -222,7 +223,7 @@ async function main() {
|
||||
await page.executeJavaScript("document.getElementById('bookmarks-menu').click()");
|
||||
await wait(100);
|
||||
assert.equal(await page.executeJavaScript("document.getElementById('add-app').getBoundingClientRect().right <= document.getElementById('bookmark-panel').getBoundingClientRect().left"), true);
|
||||
const screenshot = path.join(os.tmpdir(), "netcatty-host-browser-live.png");
|
||||
const screenshot = path.join(profile, "netcatty-host-browser-live.png");
|
||||
require("node:fs").writeFileSync(screenshot, (await page.capturePage()).toPNG());
|
||||
browser.setSize(720, 500);
|
||||
await wait(100);
|
||||
@@ -234,12 +235,12 @@ async function main() {
|
||||
connectionOptions: { hostId: "live-host", type: "dynamic", hostname: "fixture", username: "test" },
|
||||
});
|
||||
assert.equal((await snapshot()).theme, "light");
|
||||
const lightScreenshot = path.join(os.tmpdir(), "netcatty-host-browser-live-light.png");
|
||||
const lightScreenshot = path.join(profile, "netcatty-host-browser-live-light.png");
|
||||
require("node:fs").writeFileSync(lightScreenshot, (await page.capturePage()).toPNG());
|
||||
await page.executeJavaScript("document.getElementById('downloads-menu').click()");
|
||||
for (let i = 0; i < 20 && !(await snapshot()).downloadsPanelOpen; i += 1) await wait(100);
|
||||
assert.equal(await downloadsView.webContents.executeJavaScript("document.body.classList.contains('dark')"), false);
|
||||
const lightDownloadsScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloads-light.png"));
|
||||
const lightDownloadsScreenshot = await captureView(downloadsView, path.join(profile, "netcatty-host-browser-downloads-light.png"));
|
||||
await downloadsView.webContents.executeJavaScript("document.getElementById('close').click()");
|
||||
assert.equal(browser.isMinimizable(), true);
|
||||
assert.equal(browser.isMaximizable(), true);
|
||||
|
||||
@@ -6,7 +6,8 @@ const crypto = require("node:crypto");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
|
||||
const { Server } = require("ssh2");
|
||||
const electron = require("electron");
|
||||
const { app, BrowserWindow, WebContentsView } = electron;
|
||||
@@ -62,141 +63,118 @@ async function fixture(label) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
app.setPath("userData", path.join(os.tmpdir(), `netcatty-browser-panel-${process.pid}`));
|
||||
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-panel-"));
|
||||
app.setPath("userData", profile);
|
||||
fs.mkdirSync(path.join(profile, "downloads"));
|
||||
app.setPath("downloads", path.join(profile, "downloads"));
|
||||
const a = await fixture("Host A");
|
||||
const b = await fixture("Host B");
|
||||
const owner = new BrowserWindow({ width: 1100, height: 760, show: true });
|
||||
await owner.loadURL("data:text/html,<h1>Terminal workspace</h1>");
|
||||
const ownerEvents = ["blur", "resize", "maximize", "unmaximize", "closed"];
|
||||
const beforeListeners = ownerEvents.map((name) => owner.listenerCount(name));
|
||||
const manager = createHostBrowserManager({ electron, app, portForwardingBridge });
|
||||
const handlers = new Map();
|
||||
const manager = createHostBrowserManager({ electron: { ...electron, ipcMain: {
|
||||
handle(name, handler) { handlers.set(name, handler); electron.ipcMain.handle(name, handler); },
|
||||
} }, app, portForwardingBridge });
|
||||
const event = { sender: owner.webContents };
|
||||
const bounds = { x: 680, y: 50, width: 400, height: 650 };
|
||||
const bounds = { x: 680, y: 110, width: 400, height: 590 };
|
||||
const layout = (browserId, visible, nextBounds = bounds) => manager.updatePanelLayout(event, { browserId, visible, bounds: nextBounds });
|
||||
const state = (id) => manager.getPanelState(event, id);
|
||||
const command = (id, input) => manager.runCommand(event, input, id);
|
||||
const call = (name, ...args) => handlers.get(`netcatty:host-browser:${name}`)(event, ...args);
|
||||
const rootFor = (url) => owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView && child.webContents.getURL() === url));
|
||||
const open = async (id, host) => {
|
||||
manager.openPanelUrl(event, { browserId: id, url: host.url });
|
||||
layout(id, true);
|
||||
await manager.open(event, { browserId: id, embedded: true, hostId: host.options.hostId,
|
||||
label: host.options.hostId, theme: "light", connectionOptions: host.options });
|
||||
return owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
|
||||
&& child.webContents.getURL().endsWith("browser.html")));
|
||||
await waitFor(() => state(id).tabs[0].title, host.options.hostId);
|
||||
return rootFor(host.url);
|
||||
};
|
||||
manager.openPanelUrl(event, { browserId: "workspace:session-a", url: a.url });
|
||||
const rootA = await open("workspace:session-a", a);
|
||||
const chromeA = rootA.children[0].webContents;
|
||||
const snapshotA = () => chromeA.executeJavaScript("window.hostBrowser.snapshot()");
|
||||
assert.equal((await snapshotA()).embedded, true);
|
||||
assert.equal(await chromeA.executeJavaScript("document.querySelector('#route') === null"), true);
|
||||
assert.equal(await chromeA.executeJavaScript("document.documentElement.scrollWidth <= innerWidth"), true);
|
||||
await waitFor(async () => (await snapshotA()).tabs[0].title, "Host A");
|
||||
const chromeBottom = await chromeA.executeJavaScript("document.querySelector('.chrome').getBoundingClientRect().bottom");
|
||||
const pageA = rootA.children.find((child) => child instanceof WebContentsView && child.webContents.getURL() === a.url);
|
||||
assert.equal(pageA.getBounds().y, chromeBottom, "page must meet the compact toolbar without a gap or overlap");
|
||||
await chromeA.executeJavaScript("document.querySelector('.tab').dispatchEvent(new MouseEvent('contextmenu', {bubbles:true,clientX:30,clientY:20}))");
|
||||
await waitFor(() => rootA.children.includes(pageA), false);
|
||||
await chromeA.executeJavaScript("document.querySelector('#tab-icon-change').click()");
|
||||
assert.equal(rootA.children.includes(pageA), false, "web content must not cover the icon editor");
|
||||
assert.equal(await chromeA.executeJavaScript("document.querySelector('#tab-icon-editor').hidden"), false);
|
||||
await chromeA.executeJavaScript("document.querySelector('#tab-icon-cancel').click()");
|
||||
await waitFor(() => rootA.children.includes(pageA), true);
|
||||
layout("workspace:session-a", false);
|
||||
const idA = "workspace:session-a";
|
||||
const rootA = await open(idA, a);
|
||||
assert.equal(rootA.children.length, 1, "embedded browser chrome must be part of the main renderer");
|
||||
const pageA = rootA.children[0];
|
||||
const pageContentsA = pageA.webContents;
|
||||
assert.equal(pageA.getBounds().y, 0, "the native view contains only the webpage viewport");
|
||||
await pageA.webContents.executeJavaScript("window.overlayProbe = 'keep this page'");
|
||||
const captured = await manager.updatePanelLayout(event, { browserId: idA, visible: true, occluded: true, bounds });
|
||||
assert.equal(owner.contentView.children.includes(rootA), false);
|
||||
// A locked desktop cannot grant focus; keep the focus check enabled by default.
|
||||
if (!process.env.NETCATTY_BROWSER_SMOKE_SKIP_FOCUS) await waitFor(() => owner.webContents.isFocused(), true);
|
||||
assert.equal(captured.layers.length, 1);
|
||||
assert.equal(electron.nativeImage.createFromDataURL(captured.layers[0].image).isEmpty(), false);
|
||||
layout(idA, true);
|
||||
assert.equal(await pageA.webContents.executeJavaScript("window.overlayProbe"), "keep this page");
|
||||
let finishCapture;
|
||||
const originalCapture = pageA.webContents.capturePage.bind(pageA.webContents);
|
||||
pageA.webContents.capturePage = async () => { await new Promise((resolve) => { finishCapture = resolve; }); return originalCapture(); };
|
||||
const late = manager.updatePanelLayout(event, { browserId: idA, visible: true, occluded: true, bounds });
|
||||
assert.ok(finishCapture);
|
||||
layout(idA, true); finishCapture();
|
||||
assert.equal(await late, false);
|
||||
assert.equal(owner.contentView.children.includes(rootA), true);
|
||||
pageA.webContents.capturePage = originalCapture;
|
||||
layout(idA, false);
|
||||
const rootB = await open("workspace:session-b", b);
|
||||
const chromeB = rootB.children[0].webContents;
|
||||
await chromeB.executeJavaScript(`window.hostBrowser.command({type:'navigate',url:${JSON.stringify(b.url)}})`);
|
||||
await waitFor(async () => (await chromeB.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title, "Host B");
|
||||
assert.equal(BrowserWindow.getAllWindows().length, 1, "panels must not create popup windows");
|
||||
assert.equal(BrowserWindow.getAllWindows().length, 1);
|
||||
layout("workspace:session-b", false);
|
||||
layout("workspace:session-a", true, { x: 20, y: 80, width: 350, height: 500 });
|
||||
assert.deepEqual(rootA.getBounds(), { x: 20, y: 80, width: 350, height: 500 });
|
||||
assert.equal((await snapshotA()).tabs[0].title, "Host A", "focus switch preserves the earlier page");
|
||||
assert.equal(owner.contentView.children.includes(rootB), false, "old context must leave the screen");
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await chromeA.executeJavaScript("window.hostBrowser.command({type:'new-tab'})");
|
||||
}
|
||||
assert.equal(await chromeA.executeJavaScript(`(() => {
|
||||
const button = document.querySelector('#new-tab').getBoundingClientRect();
|
||||
return document.documentElement.scrollWidth <= innerWidth && button.left >= 0 && button.right <= innerWidth;
|
||||
})()`), true, "tabs must scroll within a narrow pane and leave the add button reachable");
|
||||
await chromeA.executeJavaScript("document.querySelector('#add-app').click()");
|
||||
assert.equal(await chromeA.executeJavaScript("document.querySelector('#bookmark-panel').getBoundingClientRect().width <= innerWidth"), true);
|
||||
await chromeA.executeJavaScript("document.querySelector('#bookmark-close').click()");
|
||||
await chromeA.executeJavaScript("window.hostBrowser.command({type:'set-download-panel',open:true})");
|
||||
const downloads = rootA.children.find((child) => child instanceof WebContentsView && child.webContents.getURL().endsWith("downloads.html"));
|
||||
assert.ok(downloads);
|
||||
assert.ok(downloads.getBounds().width <= rootA.getBounds().width);
|
||||
assert.equal(downloads.getBounds().y, chromeBottom, "downloads must start below the compact toolbar");
|
||||
layout(idA, true, { x: 20, y: 100, width: 350, height: 500 });
|
||||
assert.equal(owner.contentView.children.includes(rootB), false);
|
||||
assert.equal(state(idA).tabs[0].title, "Host A");
|
||||
for (let index = 0; index < 6; index++) await command(idA, { type: "new-tab" });
|
||||
assert.equal(state(idA).tabs.length, 7);
|
||||
const otherOwner = new BrowserWindow({ show: false });
|
||||
assert.throws(() => manager.updatePanelLayout({ sender: otherOwner.webContents }, { browserId: "workspace:session-a", visible: false }), /another terminal window/);
|
||||
assert.throws(() => manager.openPanelUrl({ sender: otherOwner.webContents }, { browserId: "workspace:session-a", url: a.url }), /another terminal window/);
|
||||
assert.throws(() => manager.openPanelUrl(event, { browserId: "workspace:session-a", url: "file:///etc/passwd" }), /HTTP and HTTPS/);
|
||||
assert.throws(() => manager.getPanelState({ sender: otherOwner.webContents }, idA), /Untrusted/);
|
||||
assert.throws(() => manager.getPanelState({ sender: pageA.webContents }, idA), /Untrusted/);
|
||||
assert.throws(() => manager.updatePanelLayout({ sender: otherOwner.webContents }, { browserId: idA, visible: false }), /another terminal window/);
|
||||
assert.throws(() => manager.openPanelUrl(event, { browserId: idA, url: "file:\/\/\/etc/passwd" }), /HTTP and HTTPS/);
|
||||
otherOwner.destroy();
|
||||
assert.ok(a.connections() > 0 && b.connections() > 0);
|
||||
manager.closePanel(event, "workspace:session-a");
|
||||
await waitFor(() => chromeA.isDestroyed(), true);
|
||||
assert.equal(owner.isDestroyed(), false, "closing browser must keep the terminal window alive");
|
||||
const bookmark = call("bookmarks:upsert", { title: "Remote app", url: a.url, scope: "host" }, idA);
|
||||
assert.ok(call("bookmarks:list", idA).some((item) => item.id === bookmark.id));
|
||||
assert.equal(call("bookmarks:list", "workspace:session-b").some((item) => item.id === bookmark.id), false);
|
||||
await assert.rejects(command(idA, { type: "navigate", url: "file:///etc/passwd" }), /HTTP and HTTPS/);
|
||||
manager.closePanel(event, idA);
|
||||
await waitFor(() => pageContentsA.isDestroyed(), true);
|
||||
assert.equal(owner.webContents.isDestroyed(), false, "closing a browser must never close its main renderer");
|
||||
|
||||
const localSite = http.createServer((request, response) => {
|
||||
if (request.url === '/slow.bin') {
|
||||
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': 1048576 });
|
||||
response.write(Buffer.alloc(16384));
|
||||
return;
|
||||
}
|
||||
response.end('<title>Local browser</title><h1>Local browser</h1>');
|
||||
if (request.url === '/slow.bin') { response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': 1048576 }); response.write(Buffer.alloc(16384)); return; }
|
||||
response.end('<title>Local browser</title>');
|
||||
});
|
||||
await new Promise((resolve) => localSite.listen(0, "127.0.0.1", resolve));
|
||||
manager.openPanelUrl(event, { browserId: "local:session", url: `http://127.0.0.1:${localSite.address().port}/` });
|
||||
layout("local:session", true);
|
||||
await manager.open(event, { browserId: "local:session", embedded: true, local: true,
|
||||
hostId: "local", label: "Local terminal", theme: "light" });
|
||||
const localRoot = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
|
||||
&& child.webContents.getURL().endsWith("browser.html")));
|
||||
const localChrome = localRoot.children[0].webContents;
|
||||
await waitFor(async () => (await localChrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title, "Local browser");
|
||||
assert.equal((await localChrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs.length, 1, "first terminal link reuses the blank tab");
|
||||
const localPage = localRoot.children.find((child) => child instanceof WebContentsView && child !== localRoot.children[0]);
|
||||
layout('local:session', false);
|
||||
const localUrl = `http://127.0.0.1:${localSite.address().port}/`;
|
||||
manager.openPanelUrl(event, { browserId: 'local:session', url: localUrl });
|
||||
layout('local:session', true);
|
||||
await manager.open(event, { browserId: 'local:session', embedded: true, local: true, hostId: 'local', label: 'Local', theme: 'light' });
|
||||
await waitFor(() => state('local:session').tabs[0].title, 'Local browser');
|
||||
assert.equal(state('local:session').tabs.length, 1);
|
||||
const localPage = rootFor(localUrl).children[0];
|
||||
layout('local:peer', true);
|
||||
await manager.open(event, { browserId: 'local:peer', embedded: true, local: true,
|
||||
hostId: 'local', label: 'Same host peer', theme: 'light' });
|
||||
const peerRoot = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
|
||||
&& child.webContents.getURL().endsWith('browser.html')));
|
||||
await peerRoot.children[0].webContents.executeJavaScript("window.hostBrowser.command({type:'set-download-panel',open:true})");
|
||||
const peerDownloads = peerRoot.children.find((child) => child instanceof WebContentsView && child.webContents.getURL().endsWith('downloads.html')).webContents;
|
||||
let activeDownload;
|
||||
await manager.open(event, { browserId: 'local:peer', embedded: true, local: true, hostId: 'local', label: 'Peer', theme: 'light' });
|
||||
let download;
|
||||
const startDownload = async () => {
|
||||
activeDownload = undefined;
|
||||
localPage.webContents.session.once('will-download', (_downloadEvent, item) => {
|
||||
activeDownload = item;
|
||||
item.setSavePath(path.join(app.getPath('userData'), 'closing-download.bin'));
|
||||
});
|
||||
localPage.webContents.downloadURL(`http://127.0.0.1:${localSite.address().port}/slow.bin`);
|
||||
await waitFor(() => Boolean(activeDownload), true);
|
||||
download = undefined;
|
||||
localPage.webContents.session.once('will-download', (_event, item) => { download = item; });
|
||||
localPage.webContents.downloadURL(`${localUrl}slow.bin`);
|
||||
await waitFor(() => Boolean(download), true);
|
||||
};
|
||||
await startDownload();
|
||||
const items = call('downloads:list', 'local:peer');
|
||||
assert.ok(items.some((item) => item.status === 'progressing' || item.status === 'waiting'));
|
||||
let cancelled = false;
|
||||
activeDownload.once('done', (_downloadEvent, status) => { cancelled = status === 'cancelled'; });
|
||||
await waitFor(() => peerDownloads.executeJavaScript("Boolean(document.querySelector('button[title=\"Cancel download\"]'))"), true);
|
||||
await peerDownloads.executeJavaScript("window.hostBrowserDownloads.list().then(items => window.hostBrowserDownloads.command('cancel', items[0].id))");
|
||||
download.once('done', (_event, status) => { cancelled = status === 'cancelled'; });
|
||||
await call('downloads:command', { type: 'cancel', id: items[0].id }, 'local:peer');
|
||||
await waitFor(() => cancelled, true);
|
||||
await waitFor(() => peerDownloads.executeJavaScript("Boolean(document.querySelector('button[title=\"Cancel download\"]'))"), false);
|
||||
manager.closePanel(event, 'local:peer');
|
||||
await startDownload();
|
||||
cancelled = false;
|
||||
activeDownload.once('done', (_downloadEvent, status) => { cancelled = status === 'cancelled'; });
|
||||
await startDownload(); cancelled = false;
|
||||
download.once('done', (_event, status) => { cancelled = status === 'cancelled'; });
|
||||
manager.closePanel(event, 'local:session');
|
||||
await waitFor(() => cancelled, true);
|
||||
await waitFor(() => localChrome.isDestroyed(), true);
|
||||
assert.equal(owner.isDestroyed(), false, 'closing during a download must keep the terminal alive');
|
||||
manager.closeAll();
|
||||
localSite.closeAllConnections();
|
||||
localSite.close();
|
||||
assert.equal(owner.isDestroyed(), false);
|
||||
assert.equal(owner.webContents.isDestroyed(), false);
|
||||
manager.closeAll(); localSite.closeAllConnections(); localSite.close();
|
||||
assert.deepEqual(ownerEvents.map((name) => owner.listenerCount(name)), beforeListeners);
|
||||
owner.destroy();
|
||||
a.close(); b.close();
|
||||
console.log(JSON.stringify({ ok: true, embedded: true, hostSwitching: true, pageRetention: true, popups: 0 }));
|
||||
owner.destroy(); a.close(); b.close();
|
||||
console.log(JSON.stringify({ ok: true, rendererChrome: true, hostSwitching: true, pageRetention: true, scopedControls: true, downloads: true }));
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.whenReady().then(main).catch((error) => { console.error(error); app.exit(1); });
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user