mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118c015bd5 |
No files matched your search
@@ -24,7 +24,7 @@ export const enMonitorMessages = {
|
||||
"monitor.stopAll": "Stop all",
|
||||
"monitor.list": "List",
|
||||
"monitor.charts": "Charts",
|
||||
"monitor.search": "Search hosts, groups, tags or notes",
|
||||
"monitor.search": "Search name, address or group",
|
||||
"monitor.interval": "Update interval",
|
||||
"monitor.range": "Trend range",
|
||||
"monitor.seconds": "Every {count}s",
|
||||
@@ -34,13 +34,6 @@ 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",
|
||||
@@ -60,6 +53,8 @@ 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",
|
||||
|
||||
@@ -942,69 +942,4 @@ 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…',
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const esMonitorMessages = {
|
||||
"monitor.stopAll": "Detener todo",
|
||||
"monitor.list": "Lista",
|
||||
"monitor.charts": "Gráficos",
|
||||
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
|
||||
"monitor.search": "Buscar nombre, dirección o grupo",
|
||||
"monitor.interval": "Intervalo de actualización",
|
||||
"monitor.range": "Rango de tendencias",
|
||||
"monitor.seconds": "Cada {count} s",
|
||||
@@ -34,13 +34,6 @@ 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",
|
||||
@@ -60,6 +53,8 @@ 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",
|
||||
|
||||
@@ -904,69 +904,4 @@ 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…',
|
||||
};
|
||||
@@ -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,13 +33,6 @@ 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": "Состояние",
|
||||
@@ -59,6 +52,8 @@ export const ruMonitorMessages = {
|
||||
"monitor.memoryTrend": "График памяти",
|
||||
"monitor.downloadTrend": "График приёма",
|
||||
"monitor.uploadTrend": "График передачи",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
|
||||
"monitor.name": "Название панели",
|
||||
"monitor.description": "Описание (необязательно)",
|
||||
"monitor.chooseHosts": "Выбрать серверы",
|
||||
|
||||
@@ -949,69 +949,4 @@ 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': 'Сохранить как…',
|
||||
};
|
||||
@@ -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,13 +32,6 @@ 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": "状态",
|
||||
@@ -58,6 +51,8 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.memoryTrend": "内存趋势",
|
||||
"monitor.downloadTrend": "下载趋势",
|
||||
"monitor.uploadTrend": "上传趋势",
|
||||
"monitor.memoryOnlyHint":
|
||||
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
|
||||
"monitor.name": "面板名称",
|
||||
"monitor.description": "说明(可选)",
|
||||
"monitor.chooseHosts": "选择主机",
|
||||
|
||||
@@ -1028,69 +1028,4 @@ 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': '另存为…',
|
||||
};
|
||||
@@ -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,13 +32,6 @@ 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": "狀態",
|
||||
@@ -58,6 +51,8 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.memoryTrend": "記憶體趨勢",
|
||||
"monitor.downloadTrend": "下載趨勢",
|
||||
"monitor.uploadTrend": "上傳趨勢",
|
||||
"monitor.memoryOnlyHint":
|
||||
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
|
||||
"monitor.name": "面板名稱",
|
||||
"monitor.description": "說明(選填)",
|
||||
"monitor.chooseHosts": "選擇主機",
|
||||
|
||||
@@ -1028,69 +1028,4 @@ 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': '另存新檔…',
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
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.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.at, historyId: host.historyId }])),
|
||||
};
|
||||
}
|
||||
export function publishMonitorUnavailable() {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
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) };
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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 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 = { isConnected: true, closest: () => null,
|
||||
getBoundingClientRect: () => new env.window.DOMRect(200, 100, 500, 600) };
|
||||
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 () => {
|
||||
const pending = [...frames.values()]; frames.clear();
|
||||
for (const callback of pending) callback(0);
|
||||
});
|
||||
};
|
||||
return { layouts, overlay, tick, panel: () => panel,
|
||||
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, []);
|
||||
});
|
||||
@@ -2,16 +2,6 @@ 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;
|
||||
@@ -32,47 +22,32 @@ 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 previous = '';
|
||||
let disposed = false;
|
||||
const update = () => {
|
||||
const element = elementRef.current;
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// 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 covered = modal || (!!rect && Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
)).some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
// 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]');
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
|
||||
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 layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
browserId, visible, occluded: visible && covered, bounds, theme, themeTokens,
|
||||
};
|
||||
const next = JSON.stringify(layout);
|
||||
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
|
||||
if (next !== previous) {
|
||||
previous = next;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || previous !== next) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
}).catch(() => {});
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
|
||||
}
|
||||
if (isVisible) frame = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(frame);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
};
|
||||
@@ -83,9 +58,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
opened.current = true;
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).then(() => {
|
||||
if (mountedBrowserId.current === browserId) setReadyId(browserId);
|
||||
}).catch((reason: unknown) => {
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
|
||||
opened.current = false;
|
||||
if (cancelled) return;
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
@@ -107,6 +80,5 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
};
|
||||
}, [browserId]);
|
||||
|
||||
return { elementRef, error, ready: readyId === browserId, snapshot: isVisible && snapshot?.browserId === browserId ? snapshot.layers : [],
|
||||
retry: () => setAttempt((value) => value + 1) };
|
||||
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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,137 +1,16 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ArrowRight, Bookmark, Download, Edit2, Loader2, Plus, RotateCw, Square, Trash2, X } from 'lucide-react';
|
||||
import React from '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: mountError, snapshot, retry, ready } = useHostBrowserPanel(props);
|
||||
const browser = useHostBrowserControls(props.browserId, ready && props.isVisible);
|
||||
const { elementRef, error, retry } = useHostBrowserPanel(props);
|
||||
const { t } = useI18n();
|
||||
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>
|
||||
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>}
|
||||
</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,150 +0,0 @@
|
||||
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 }) {
|
||||
);
|
||||
}
|
||||
|
||||
const EditorWindowPaneHost = React.memo(function EditorWindowPaneHost({
|
||||
function EditorWindowPaneHost({
|
||||
tabId,
|
||||
isVisible,
|
||||
settings,
|
||||
@@ -130,16 +130,6 @@ const EditorWindowPaneHost = React.memo(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;
|
||||
@@ -161,35 +151,18 @@ const EditorWindowPaneHost = React.memo(function EditorWindowPaneHost({
|
||||
hotkeyScheme={settings.hotkeyScheme}
|
||||
keyBindings={settings.keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
onRequestClose={handleRequestClose}
|
||||
onDockToMain={handleDock}
|
||||
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)}
|
||||
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 }) {
|
||||
@@ -198,12 +171,23 @@ 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;
|
||||
@@ -237,16 +221,16 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
await closeWindow();
|
||||
return true;
|
||||
}
|
||||
setActiveTabId((current) => current === tabId
|
||||
? remaining[remaining.length - 1]?.id ?? null
|
||||
: current);
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
|
||||
}
|
||||
return true;
|
||||
})().finally(() => {
|
||||
closeInFlightRef.current = null;
|
||||
});
|
||||
closeInFlightRef.current = task;
|
||||
return task;
|
||||
}, [closeTab, closeWindow, setActiveTabId]);
|
||||
}, [activeTabId, closeTab, closeWindow, setActiveTabId]);
|
||||
|
||||
const handleDock = useCallback(async (tabId: EditorTabId) => {
|
||||
const result = await dockDetachedEditorTab(tabId);
|
||||
@@ -260,14 +244,10 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
await closeWindow();
|
||||
return;
|
||||
}
|
||||
setActiveTabId((current) => current === tabId
|
||||
? remaining[remaining.length - 1]?.id ?? null
|
||||
: current);
|
||||
}, [closeWindow, setActiveTabId, t]);
|
||||
|
||||
const handleRequestClose = useCallback((tabId: EditorTabId) => {
|
||||
void closeTabAndMaybeWindow(tabId);
|
||||
}, [closeTabAndMaybeWindow]);
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
|
||||
}
|
||||
}, [activeTabId, closeWindow, setActiveTabId, t]);
|
||||
|
||||
const handleWindowClose = useCallback(async () => {
|
||||
// Native close owns confirmation and blocks new transfers until it settles.
|
||||
@@ -316,7 +296,6 @@ 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">
|
||||
@@ -342,8 +321,8 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
|
||||
tabId={tab.id}
|
||||
isVisible={tab.id === activeTabId}
|
||||
settings={settings}
|
||||
onRequestClose={handleRequestClose}
|
||||
onDock={handleDock}
|
||||
onRequestClose={(id) => { void closeTabAndMaybeWindow(id); }}
|
||||
onDock={(id) => { void handleDock(id); }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -145,16 +145,10 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
|
||||
};
|
||||
|
||||
// Content Component (wraps children with scroll)
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string; scrollMode?: "custom" | "native" }> = ({
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
|
||||
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">
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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,26 +10,6 @@ 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 => ({
|
||||
@@ -181,7 +161,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("[data-monitor-cell=network]")!;
|
||||
const network = document.querySelector("tbody tr")!.children[5];
|
||||
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
|
||||
}
|
||||
});
|
||||
|
||||
+259
-368
@@ -6,7 +6,6 @@ import {
|
||||
Copy,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Gpu,
|
||||
HardDrive,
|
||||
LayoutGrid,
|
||||
List,
|
||||
@@ -39,7 +38,6 @@ 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";
|
||||
@@ -72,9 +70,6 @@ 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) =>
|
||||
@@ -219,15 +214,22 @@ 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({
|
||||
@@ -303,39 +305,28 @@ 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) =>
|
||||
(hostById.get(a)?.label ?? a).localeCompare(
|
||||
hostById.get(b)?.label ?? b,
|
||||
),
|
||||
);
|
||||
}, [panel, hostById]);
|
||||
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]);
|
||||
const rows = useMemo(() => {
|
||||
if (!panel) return [];
|
||||
const q = search.trim().toLocaleLowerCase();
|
||||
const rows = orderedHostIds.map((id) => ({
|
||||
id,
|
||||
host: hostById.get(id),
|
||||
host: hosts.find((h) => h.id === id),
|
||||
state: monitorStateSince(runtime.hosts[id], runtime.startedAt[panel.id]),
|
||||
}));
|
||||
return rows.filter(
|
||||
({ host }) =>
|
||||
!q ||
|
||||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""} ${host?.tags.join(" ") ?? ""} ${host?.notes ?? ""}`
|
||||
({ host }) => !q ||
|
||||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(q),
|
||||
);
|
||||
}, [panel, orderedHostIds, hostById, runtime, search]);
|
||||
const dragProps = (
|
||||
hostId: string,
|
||||
): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
|
||||
}, [panel, orderedHostIds, hosts, runtime, search]);
|
||||
const dragProps = (hostId: string): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
|
||||
draggable: true,
|
||||
"data-monitor-host": hostId,
|
||||
onDragStart: (event) => {
|
||||
@@ -345,12 +336,7 @@ 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);
|
||||
@@ -358,11 +344,10 @@ 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);
|
||||
},
|
||||
@@ -377,24 +362,18 @@ 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 min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
|
||||
"inline-flex 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",
|
||||
)}
|
||||
@@ -403,9 +382,7 @@ export function MonitorPanels({
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 rounded-full bg-current"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{t(`monitor.status.${status(host, state)}`)}
|
||||
</span>
|
||||
{t(`monitor.status.${status(host, state)}`)}
|
||||
</span>
|
||||
);
|
||||
const renderMetrics = (
|
||||
@@ -468,10 +445,6 @@ 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"
|
||||
@@ -674,76 +647,35 @@ export function MonitorPanels({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{!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",
|
||||
)}
|
||||
>
|
||||
<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 }) => (
|
||||
<button
|
||||
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"}
|
||||
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",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (status(host, state) !== "live") return;
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex w-full min-w-0 items-center gap-3 pr-7">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
{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">
|
||||
@@ -753,39 +685,20 @@ export function MonitorPanels({
|
||||
{host?.hostname ?? id}
|
||||
</p>
|
||||
</div>
|
||||
{stateBadge(host, state)}
|
||||
</div>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<div className={cn(status(host, state) !== "live" && "opacity-60")}>
|
||||
{renderMetrics(state)}
|
||||
<div className="mt-3">
|
||||
<div className="mt-4">
|
||||
<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-2 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
|
||||
<div className="mt-3 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
|
||||
@@ -793,10 +706,7 @@ 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
|
||||
@@ -805,169 +715,127 @@ export function MonitorPanels({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1138,9 +1006,8 @@ export function MonitorPanels({
|
||||
className={monitorAsideClass}
|
||||
/>
|
||||
)}
|
||||
{detail && detailAvailable && (
|
||||
{detail && (
|
||||
<AsidePanel
|
||||
dataSection="vault-monitor-detail"
|
||||
open
|
||||
onClose={() => setDetail(null)}
|
||||
title={hostDetail?.label ?? t("monitor.missingHost")}
|
||||
@@ -1152,7 +1019,7 @@ export function MonitorPanels({
|
||||
width="w-[380px]"
|
||||
className={monitorAsideClass}
|
||||
>
|
||||
<AsidePanelContent scrollMode="native">
|
||||
<AsidePanelContent>
|
||||
<div className="flex items-center gap-3">
|
||||
{hostDetail && <DistroAvatar host={hostDetail} fallback={hostDetail.label} size="lg" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1160,41 +1027,73 @@ export function MonitorPanels({
|
||||
{stateBadge(hostDetail, detailState)}
|
||||
</div>
|
||||
</div>
|
||||
{renderMetrics(detailState, true)}
|
||||
{detailState?.stats && (
|
||||
detailState.stats.gpu != null ||
|
||||
detailState.stats.gpuName ||
|
||||
(detailState.stats.gpuMemTotal ?? 0) > 0
|
||||
{["login", "error", "disconnected"].includes(
|
||||
detailState?.status ?? "",
|
||||
) && (
|
||||
<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} />
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
<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)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.observed", {
|
||||
count: detailState?.points.length
|
||||
@@ -1289,6 +1188,24 @@ 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
|
||||
@@ -1302,32 +1219,6 @@ 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)}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,955 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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,8 +22,6 @@ 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[];
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -352,8 +352,7 @@ function createMonitorService({
|
||||
historyId,
|
||||
status: entry?.status ?? host.status ?? "limit",
|
||||
at: shared?.at ?? 0,
|
||||
statsAt: shared?.statsAt ?? shared?.at ?? 0,
|
||||
...(append && last?.historyId === historyId && last.statsAt === (shared?.statsAt ?? shared?.at ?? 0)
|
||||
...(append && last?.historyId === historyId && last.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: 75,
|
||||
gpu: ctx._monitorStats ? null : 75,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -752,23 +752,3 @@ 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,54 +91,6 @@ 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");
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
"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,6 +1,5 @@
|
||||
/* 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) {
|
||||
@@ -1067,18 +1066,7 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
|
||||
async function getStatsForContext(session, 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);
|
||||
return sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
|
||||
}
|
||||
|
||||
async function collectServerStats(session, event, sessionId) {
|
||||
@@ -1211,7 +1199,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=')).join('; ');
|
||||
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
|
||||
|
||||
// Prefer df's filesystem type column so FUSE mounts can be identified
|
||||
// even when their source name looks like an ordinary remote path.
|
||||
@@ -1314,7 +1302,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.
|
||||
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 "")`,
|
||||
// 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('; ');
|
||||
@@ -1376,12 +1364,6 @@ 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"
|
||||
@@ -1397,13 +1379,7 @@ function createSessionOpsApi(ctx) {
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const responseStartedAt = performance.now();
|
||||
const { stdout } = await executeStatsCommand(statsCommand, {
|
||||
...statsExecOptions,
|
||||
onStream: () => {
|
||||
if (sshStatsConn) responseLatencyMs = Math.max(0, Math.round(performance.now() - responseStartedAt));
|
||||
},
|
||||
});
|
||||
const { stdout } = await executeStatsCommand(statsCommand);
|
||||
if (settled) return;
|
||||
let combinedStdout = String(stdout || '').trim();
|
||||
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
|
||||
@@ -1579,7 +1555,27 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
}
|
||||
} else if (part.startsWith('GPU:')) {
|
||||
({gpu,gpuName,gpuMemUsed,gpuMemTotal}=parseGpuStats(part));
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (part.startsWith('HOST:')) {
|
||||
hostname = part.substring(5).trim();
|
||||
} else if (part.startsWith('OS:')) {
|
||||
@@ -1748,7 +1744,6 @@ 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 { spawn, spawnSync } = require("node:child_process");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { mkdtempSync, writeFileSync, rmSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
@@ -682,8 +682,7 @@ 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 },
|
||||
]) {
|
||||
for (const monitorOnly of [false, true]) {
|
||||
test(`real stats shell preserves other metrics with ${scenario.name} (${monitorOnly ? "monitor-only" : "terminal"})`, async (t) => {
|
||||
test(`real stats shell preserves other metrics with ${scenario.name}`, async (t) => {
|
||||
if (spawnSync("timeout", ["-s", "KILL", "2", "true"]).status !== 0) {
|
||||
t.skip("GNU/BusyBox-compatible timeout is unavailable on this test host");
|
||||
return;
|
||||
@@ -691,17 +690,11 @@ 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 gpuOnly = !command.includes("NC_LATENCY_MARK") && command.includes("nvidia-smi");
|
||||
if(gpuOnly) gpuCalls++;
|
||||
const child = spawn("sh", ["-c", [
|
||||
const execution = spawnSync("sh", ["-c", [
|
||||
"uname() { printf '%s\\n' Linux; }",
|
||||
"nproc() { printf '%s\\n' 4; }",
|
||||
"top() { return 1; }",
|
||||
@@ -710,39 +703,22 @@ 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,
|
||||
});
|
||||
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();
|
||||
});
|
||||
assert.equal(execution.status, 0, execution.stderr);
|
||||
cb(null, fakeStream(execution.stdout));
|
||||
},
|
||||
},
|
||||
}]]);
|
||||
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");
|
||||
}
|
||||
const result = await makeSessionOps(sessions).getServerStats({ sender: {} }, { sessionId: "sid" });
|
||||
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 () => {
|
||||
@@ -1430,7 +1406,6 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1474,25 +1449,3 @@ 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,6 +1,5 @@
|
||||
"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();
|
||||
@@ -101,7 +100,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
return result;
|
||||
}
|
||||
// A terminal needs process and GPU fields omitted from a monitor-only query.
|
||||
// A terminal needs process/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);
|
||||
@@ -157,10 +156,9 @@ 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.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
|
||||
return { result: state.latest, at: state.at, points: state.points };
|
||||
}
|
||||
function cancelSharedStats(conn) {
|
||||
cancelMonitorGpuProbe(conn);
|
||||
const state = states.get(conn);
|
||||
if (state && !state.terminalJoined) state.controller?.abort();
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ 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()));
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"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,14 +2,13 @@
|
||||
|
||||
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(getTempFilePath("host-browser-downloads-"));
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "host-browser-downloads-"));
|
||||
try {
|
||||
const app = { getPath: () => directory };
|
||||
const file = path.join(directory, "download.bin");
|
||||
@@ -29,32 +28,3 @@ 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 "Preparing download\u2026";
|
||||
if (item.status === "waiting") return "Choose where to save\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,7 +112,6 @@ 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);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,35 +0,0 @@
|
||||
// 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, querySelectorAll: () => [], documentElement: { classList: { contains: () => false } } },
|
||||
document: { querySelector: () => null, 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 snapshot = manager.getPanelState(event, browserId);
|
||||
assert.equal(snapshot.tabs[0].url, url, "the first click must open its page without a second terminal click");
|
||||
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");
|
||||
let loadedTitle = "";
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
loadedTitle = manager.getPanelState(event, browserId).tabs[0].title;
|
||||
loadedTitle = (await chrome.executeJavaScript("window.hostBrowser.snapshot()")).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 tempDirBridge = require("../bridges/tempDirBridge.cjs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { app, BrowserWindow, WebContentsView, session, ipcMain } = require("electron");
|
||||
@@ -77,10 +77,7 @@ function makeSocksServer() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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"));
|
||||
app.setPath("userData", path.join(os.tmpdir(), `netcatty-host-browser-live-${process.pid}`));
|
||||
const socks = makeSocksServer();
|
||||
await new Promise((resolve) => socks.listen(0, "127.0.0.1", resolve));
|
||||
const port = socks.address().port;
|
||||
@@ -140,7 +137,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(profile, "netcatty-host-browser-app-form.png");
|
||||
const formScreenshot = path.join(os.tmpdir(), "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()");
|
||||
@@ -158,9 +155,11 @@ 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(profile, "netcatty-host-browser-downloads.png"));
|
||||
const downloadPath = path.join(app.getPath("downloads"), "host-browser-test.bin");
|
||||
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 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];
|
||||
@@ -183,7 +182,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(profile, "netcatty-host-browser-downloaded.png"));
|
||||
const downloadedScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "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);
|
||||
@@ -223,7 +222,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(profile, "netcatty-host-browser-live.png");
|
||||
const screenshot = path.join(os.tmpdir(), "netcatty-host-browser-live.png");
|
||||
require("node:fs").writeFileSync(screenshot, (await page.capturePage()).toPNG());
|
||||
browser.setSize(720, 500);
|
||||
await wait(100);
|
||||
@@ -235,12 +234,12 @@ async function main() {
|
||||
connectionOptions: { hostId: "live-host", type: "dynamic", hostname: "fixture", username: "test" },
|
||||
});
|
||||
assert.equal((await snapshot()).theme, "light");
|
||||
const lightScreenshot = path.join(profile, "netcatty-host-browser-live-light.png");
|
||||
const lightScreenshot = path.join(os.tmpdir(), "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(profile, "netcatty-host-browser-downloads-light.png"));
|
||||
const lightDownloadsScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "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,8 +6,7 @@ const crypto = require("node:crypto");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
|
||||
const os = require("node:os");
|
||||
const { Server } = require("ssh2");
|
||||
const electron = require("electron");
|
||||
const { app, BrowserWindow, WebContentsView } = electron;
|
||||
@@ -63,118 +62,141 @@ async function fixture(label) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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"));
|
||||
app.setPath("userData", path.join(os.tmpdir(), `netcatty-browser-panel-${process.pid}`));
|
||||
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 handlers = new Map();
|
||||
const manager = createHostBrowserManager({ electron: { ...electron, ipcMain: {
|
||||
handle(name, handler) { handlers.set(name, handler); electron.ipcMain.handle(name, handler); },
|
||||
} }, app, portForwardingBridge });
|
||||
const manager = createHostBrowserManager({ electron, app, portForwardingBridge });
|
||||
const event = { sender: owner.webContents };
|
||||
const bounds = { x: 680, y: 110, width: 400, height: 590 };
|
||||
const bounds = { x: 680, y: 50, width: 400, height: 650 };
|
||||
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 });
|
||||
await waitFor(() => state(id).tabs[0].title, host.options.hostId);
|
||||
return rootFor(host.url);
|
||||
return owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
|
||||
&& child.webContents.getURL().endsWith("browser.html")));
|
||||
};
|
||||
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 });
|
||||
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);
|
||||
assert.equal(owner.contentView.children.includes(rootA), false);
|
||||
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);
|
||||
// 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);
|
||||
const rootB = await open("workspace:session-b", b);
|
||||
assert.equal(BrowserWindow.getAllWindows().length, 1);
|
||||
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");
|
||||
layout("workspace:session-b", false);
|
||||
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);
|
||||
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");
|
||||
const otherOwner = new BrowserWindow({ show: false });
|
||||
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/);
|
||||
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/);
|
||||
otherOwner.destroy();
|
||||
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");
|
||||
|
||||
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 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>');
|
||||
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>');
|
||||
});
|
||||
await new Promise((resolve) => localSite.listen(0, "127.0.0.1", resolve));
|
||||
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];
|
||||
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);
|
||||
layout('local:peer', true);
|
||||
await manager.open(event, { browserId: 'local:peer', embedded: true, local: true, hostId: 'local', label: 'Peer', theme: 'light' });
|
||||
let download;
|
||||
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;
|
||||
const startDownload = async () => {
|
||||
download = undefined;
|
||||
localPage.webContents.session.once('will-download', (_event, item) => { download = item; });
|
||||
localPage.webContents.downloadURL(`${localUrl}slow.bin`);
|
||||
await waitFor(() => Boolean(download), true);
|
||||
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);
|
||||
};
|
||||
await startDownload();
|
||||
const items = call('downloads:list', 'local:peer');
|
||||
assert.ok(items.some((item) => item.status === 'progressing' || item.status === 'waiting'));
|
||||
let cancelled = false;
|
||||
download.once('done', (_event, status) => { cancelled = status === 'cancelled'; });
|
||||
await call('downloads:command', { type: 'cancel', id: items[0].id }, 'local:peer');
|
||||
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))");
|
||||
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;
|
||||
download.once('done', (_event, status) => { cancelled = status === 'cancelled'; });
|
||||
await startDownload();
|
||||
cancelled = false;
|
||||
activeDownload.once('done', (_downloadEvent, status) => { cancelled = status === 'cancelled'; });
|
||||
manager.closePanel(event, 'local:session');
|
||||
await waitFor(() => cancelled, true);
|
||||
assert.equal(owner.webContents.isDestroyed(), false);
|
||||
manager.closeAll(); localSite.closeAllConnections(); localSite.close();
|
||||
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.deepEqual(ownerEvents.map((name) => owner.listenerCount(name)), beforeListeners);
|
||||
owner.destroy(); a.close(); b.close();
|
||||
console.log(JSON.stringify({ ok: true, rendererChrome: true, hostSwitching: true, pageRetention: true, scopedControls: true, downloads: true }));
|
||||
owner.destroy();
|
||||
a.close(); b.close();
|
||||
console.log(JSON.stringify({ ok: true, embedded: true, hostSwitching: true, pageRetention: true, popups: 0 }));
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.whenReady().then(main).catch((error) => { console.error(error); app.exit(1); });
|
||||
@@ -1,101 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// Run: electron electron/hostBrowser/hostBrowser.pdf.live.test.cjs
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const electron = require("electron");
|
||||
const { app, BrowserWindow, WebContentsView } = electron;
|
||||
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
|
||||
const { createHostBrowserManager } = require("./hostBrowserManager.cjs");
|
||||
const portForwardingBridge = require("../bridges/portForwardingBridge.cjs");
|
||||
|
||||
// A real, valid PDF whose body can be held open independently of its headers.
|
||||
function pdf() {
|
||||
let source = "%PDF-1.4\n";
|
||||
const offsets = [0];
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 400] >>",
|
||||
];
|
||||
for (const [index, object] of objects.entries()) {
|
||||
offsets.push(Buffer.byteLength(source));
|
||||
source += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
}
|
||||
source += `%${"padding ".repeat(1024 * 128)}\n`;
|
||||
const start = Buffer.byteLength(source);
|
||||
source += `xref\n0 4\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n${start}\n%%EOF\n`;
|
||||
return Buffer.from(source);
|
||||
}
|
||||
|
||||
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-pdf-"));
|
||||
app.setPath("userData", profile);
|
||||
fs.mkdirSync(path.join(profile, "downloads"));
|
||||
app.setPath("downloads", path.join(profile, "downloads"));
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(read, label) {
|
||||
for (let index = 0; index < 120; index++) { if (await read()) return; await wait(25); }
|
||||
assert.ok(await read(), label);
|
||||
}
|
||||
let owner, manager, site;
|
||||
const watchdog = setTimeout(() => app.exit(1), 20000);
|
||||
async function main() {
|
||||
const body = process.env.NETCATTY_PDF_FIXTURE ? fs.readFileSync(process.env.NETCATTY_PDF_FIXTURE) : pdf();
|
||||
let pending, sent = 0;
|
||||
site = http.createServer((request, response) => {
|
||||
if (request.url === "/failed") { request.socket.destroy(); return; }
|
||||
if (request.url === "/redirect") { response.writeHead(302, { location: "/slow.pdf" }); response.end(); return; }
|
||||
if (request.url === "/slow.pdf") {
|
||||
response.writeHead(200, { "content-type": "application/pdf;charset=UTF-8", "content-length": body.length });
|
||||
response.write(body.subarray(0, 65536)); sent = 65536; pending = response;
|
||||
return;
|
||||
}
|
||||
response.end('<title>PDF download fixture</title><a href="/slow.pdf">PDF</a>');
|
||||
});
|
||||
await new Promise((resolve) => site.listen(0, "127.0.0.1", resolve));
|
||||
const url = `http://127.0.0.1:${site.address().port}/`;
|
||||
owner = new BrowserWindow({ width: 900, height: 700, show: true });
|
||||
await owner.loadURL("data:text/html,<h1>PDF download acceptance</h1>");
|
||||
const handlers = new Map();
|
||||
manager = createHostBrowserManager({ electron: { ...electron, ipcMain: { handle(name, handler) { handlers.set(name, handler); } } }, app, portForwardingBridge });
|
||||
const event = { sender: owner.webContents }, browserId = "local:pdf";
|
||||
manager.openPanelUrl(event, { browserId, url });
|
||||
manager.updatePanelLayout(event, { browserId, visible: true, bounds: { x: 0, y: 80, width: 900, height: 600 } });
|
||||
await manager.open(event, { browserId, embedded: true, local: true, hostId: "local", label: "Local", theme: "light" });
|
||||
await until(() => manager.getPanelState(event, browserId).tabs[0].title === "PDF download fixture", "fixture loaded");
|
||||
const root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView));
|
||||
const page = root.children[0].webContents;
|
||||
const downloads = () => handlers.get("netcatty:host-browser:downloads:list")(event, browserId);
|
||||
for (const scenario of ["link", "redirect", "iframe", "address"]) {
|
||||
pending = undefined;
|
||||
const previous = new Set(downloads().map((item) => item.id));
|
||||
const current = () => downloads().find((item) => !previous.has(item.id));
|
||||
let navigation;
|
||||
if (scenario === "address") {
|
||||
navigation = manager.runCommand(event, { type: "navigate", url: `${url}slow.pdf` }, browserId)
|
||||
.then(() => null, (error) => error);
|
||||
} else await page.executeJavaScript(`(() => {
|
||||
const element=document.createElement(${JSON.stringify(scenario === "iframe" ? "iframe" : "a")});
|
||||
const url=${JSON.stringify(scenario === "redirect" ? "/redirect" : "/slow.pdf")};
|
||||
if(element.tagName==='IFRAME') element.src=url; else element.href=url;
|
||||
document.body.append(element); if(element.tagName==='A') element.click();
|
||||
})()`);
|
||||
await until(() => pending, "PDF headers and first bytes were sent");
|
||||
await wait(1000);
|
||||
console.log(JSON.stringify({ scenario, beforeBodyFinished: true, sent, total: body.length, download: current() }));
|
||||
assert.ok(current()?.status === "progressing" && current().received < body.length, "inline PDF must appear in downloads before its response finishes");
|
||||
assert.ok(current().received > 0, "PDF bytes arrive before the response finishes");
|
||||
if (navigation) assert.equal(await navigation, null, "a PDF download is a successful address action, not a page error");
|
||||
pending.end(body.subarray(sent));
|
||||
await until(() => current()?.status === "completed", "PDF completes");
|
||||
assert.deepEqual(fs.readFileSync(current().savePath), body, "saved PDF matches original bytes");
|
||||
}
|
||||
await assert.rejects(manager.runCommand(event, { type: "navigate", url: `${url}failed` }, browserId), /ERR_/, "real navigation failures still surface");
|
||||
assert.equal(page.session.listenerCount("will-download"), 1, "download handling keeps a single session listener");
|
||||
console.log(JSON.stringify({ ok: true, earlyPdfProgress: true, redirect: true, iframe: true, addressDownload: true, realErrorsPreserved: true, profile }));
|
||||
}
|
||||
app.whenReady().then(main).catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => {
|
||||
clearTimeout(watchdog); manager?.closeAll(); owner?.destroy(); site?.closeAllConnections(); site?.close(); app.exit(process.exitCode || 0);
|
||||
});
|
||||
@@ -1,301 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// Real React chrome, production preload and native page manager in an isolated profile.
|
||||
// Run: electron electron/hostBrowser/hostBrowser.renderer.live.test.cjs
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const path = require('node:path');
|
||||
const { app, BrowserWindow, WebContentsView, ipcMain } = require('electron');
|
||||
const electron = require('electron');
|
||||
const { createHostBrowserManager } = require('./hostBrowserManager.cjs');
|
||||
const portForwardingBridge = require('../bridges/portForwardingBridge.cjs');
|
||||
const tempDirBridge = require('../bridges/tempDirBridge.cjs');
|
||||
const repo = path.join(__dirname, '../..');
|
||||
const userData = fs.mkdtempSync(tempDirBridge.getTempFilePath('browser-renderer-'));
|
||||
app.setPath('userData', userData);
|
||||
const downloadsPath = path.join(userData, 'downloads');
|
||||
fs.mkdirSync(downloadsPath);
|
||||
app.setPath('downloads', downloadsPath);
|
||||
let owner, manager, vite, site;
|
||||
let pendingPdf;
|
||||
let pendingNavigation;
|
||||
const pdfBody = Buffer.concat([Buffer.from('%PDF-1.4\n% streamed inline PDF\n'), Buffer.alloc(1048576, 32)]);
|
||||
const savedCopy = path.join(userData, 'saved-copy.txt');
|
||||
const shellActions = [];
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(read, expected, label) {
|
||||
for (let index = 0; index < 150; index++) { if (await read() === expected) return; await wait(30); }
|
||||
assert.equal(await read(), expected, label);
|
||||
}
|
||||
const run = (code) => owner.webContents.executeJavaScript(code).catch((error) => { throw new Error(`${error.message}\nWhile executing: ${code}`); });
|
||||
async function capture(file, selector) {
|
||||
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
const bounds = selector ? await run(`(() => { const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)};})()`) : undefined;
|
||||
fs.writeFileSync(file, (await owner.webContents.capturePage(bounds)).toPNG());
|
||||
}
|
||||
async function click(selector) {
|
||||
const rect = await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); element?.scrollIntoView({block:'nearest'}); const r = element?.getBoundingClientRect(); return r && {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
|
||||
assert.ok(rect, `missing ${selector}`);
|
||||
for (const type of ['mouseDown', 'mouseUp']) owner.webContents.sendInputEvent({ type, button: 'left', clickCount: 1, x: Math.round(rect.x), y: Math.round(rect.y) });
|
||||
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
}
|
||||
async function input(selector, value) {
|
||||
await until(() => run(`!!document.querySelector(${JSON.stringify(selector)})`), true, `input exists: ${selector}`);
|
||||
await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(element,${JSON.stringify(value)}); element.dispatchEvent(new Event('input',{bubbles:true})); })()`);
|
||||
}
|
||||
const watchdog = setTimeout(() => { console.error('Browser renderer acceptance timed out'); app.exit(1); }, 90000);
|
||||
|
||||
async function main() {
|
||||
const { createServer } = await import('vite');
|
||||
vite = await createServer({ root: repo, server: { host: '127.0.0.1', port: 0, strictPort: false }, clearScreen: false, logLevel: 'error' });
|
||||
await vite.listen();
|
||||
const base = `http://127.0.0.1:${vite.httpServer.address().port}`;
|
||||
site = http.createServer((request, response) => {
|
||||
if (request.url === '/slow-page') {
|
||||
response.writeHead(200, { 'content-type': 'text/html' });
|
||||
response.write('<title>Slow navigation</title><input autofocus>'); pendingNavigation = response; return;
|
||||
}
|
||||
if (request.url === '/inline.pdf') {
|
||||
response.writeHead(200, { 'content-type': 'application/pdf;charset=UTF-8', 'content-length': pdfBody.length,
|
||||
'content-disposition': "inline; filename*=UTF-8''%E6%85%A2%E9%80%9F%E6%8A%A5%E5%91%8A.pdf" });
|
||||
response.write(pdfBody.subarray(0, 65536)); pendingPdf = response; return;
|
||||
}
|
||||
if (request.url === '/report.txt') {
|
||||
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': 'attachment; filename="report.txt"' });
|
||||
response.end('Netcatty automatic download\n'); return;
|
||||
}
|
||||
if (request.url === '/slow.bin') {
|
||||
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': 1048576 });
|
||||
response.write(Buffer.alloc(16384)); return;
|
||||
}
|
||||
response.end(`<html><title>Browser acceptance page</title><body style="background:#c8f1df;color:#142a25;font:22px sans-serif;padding:25px"><h1>This page stays visible</h1><input id="draft" value="Keep my input"><button id="count" onclick="window.count=(window.count||0)+1">Count</button><a id="next" href="/next">Next</a><a id="download" href="/report.txt">Download report</a><p>${request.url}</p></body></html>`);
|
||||
});
|
||||
await new Promise((resolve) => site.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://127.0.0.1:${site.address().port}/`;
|
||||
const preload = path.join(userData, 'preload.cjs');
|
||||
fs.writeFileSync(preload, `const {contextBridge,ipcRenderer,webUtils}=require('electron'); const {createPreloadApi}=require(${JSON.stringify(path.join(repo,'electron/preload/api.cjs'))}); contextBridge.exposeInMainWorld('netcatty',createPreloadApi({ipcRenderer,webUtils}));`);
|
||||
owner = new BrowserWindow({ width: 1000, height: 800, show: true, webPreferences: { preload, contextIsolation: true, sandbox: false } });
|
||||
const errors = [];
|
||||
owner.webContents.on('console-message', (event) => { if (event.level === 'error' || event.level >= 3) errors.push(event.message); });
|
||||
owner.webContents.on('render-process-gone', (_event, details) => errors.push(JSON.stringify(details)));
|
||||
manager = createHostBrowserManager({ electron: { ...electron,
|
||||
dialog: { showSaveDialog: async () => ({ canceled: false, filePath: savedCopy }) },
|
||||
shell: { openPath: async (file) => { shellActions.push(['open', file]); return ''; }, showItemInFolder: (file) => shellActions.push(['reveal', file]) },
|
||||
}, app, portForwardingBridge });
|
||||
const event = { sender: owner.webContents };
|
||||
const browserId = 'local:renderer-acceptance';
|
||||
manager.openPanelUrl(event, { browserId, url });
|
||||
await owner.loadURL(`${base}/electron/hostBrowser/fixtures/renderer.html`);
|
||||
await until(() => run('document.querySelectorAll("[role=tab]").length'), 1, 'unified tab mounts');
|
||||
await until(() => manager.getPanelState(event, browserId).tabs[0].title, 'Browser acceptance page', 'page loads');
|
||||
let root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView && child.webContents.getURL() === url));
|
||||
assert.ok(root); assert.equal(root.children.length, 1, 'only the website is a separate native view');
|
||||
const page = root.children[0].webContents;
|
||||
await page.executeJavaScript("document.getElementById('draft').value='Unsaved browser input'");
|
||||
for (let index = 0; index < 2; index++) {
|
||||
app.focus({ steal: true });
|
||||
owner.focus(); owner.webContents.focus();
|
||||
await until(() => owner.isFocused(), true, 'acceptance window has native focus');
|
||||
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
|
||||
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
const rect = await run(`(() => { const r = document.getElementById('split-${index}').getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
|
||||
owner.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(rect.x), y: Math.round(rect.y) });
|
||||
await until(() => run('!!document.querySelector("[role=tooltip]")'), true, 'split hover tooltip opens');
|
||||
await wait(100);
|
||||
assert.equal(owner.contentView.children.includes(root), true, 'hover leaves the website live');
|
||||
assert.equal(await run('document.querySelectorAll("[data-section=host-browser-panel] img").length'), 0, 'hover does not replace the page with a frozen picture');
|
||||
await click(`#split-${index}`);
|
||||
await until(() => run('document.querySelectorAll("[data-section=host-browser-panel] img").length'), 1, 'overlapping split menu preserves a picture of the webpage');
|
||||
assert.equal(owner.contentView.children.includes(root), false, 'the split menu is above the native content');
|
||||
await click(`#choice-${index}-0`);
|
||||
await until(() => run(`document.getElementById('choice-${index}-0').dataset.chosen`), 'true', 'split menu choices are clickable');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
|
||||
await until(() => owner.contentView.children.includes(root), true, 'closing menu restores live content');
|
||||
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
|
||||
}
|
||||
await click('[aria-label="新标签页"]');
|
||||
await until(() => run('document.querySelectorAll("[role=tab]").length'), 2, 'new tab');
|
||||
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab focuses the app address input');
|
||||
assert.equal(owner.contentView.children.includes(root), false, 'the default tab must not be covered by an empty native container');
|
||||
await click('[role=tab]');
|
||||
await until(() => owner.contentView.children.includes(root), true, 'return to original tab');
|
||||
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
|
||||
page.sendInputEvent({ type: 'keyDown', keyCode: 'L', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'page shortcut focuses unified address input');
|
||||
await input('[aria-label="输入网址"]', `${url}next`);
|
||||
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
|
||||
await until(() => page.getURL(), `${url}next`, 'address navigation');
|
||||
await until(() => run('document.querySelector("[aria-label=后退]").disabled'), false, 'history back is enabled');
|
||||
await click('[aria-label="后退"]');
|
||||
await until(() => page.getURL(), url, 'back navigation');
|
||||
await click('[aria-label="应用与书签"]');
|
||||
await until(() => run('!!document.querySelector("aside[aria-label=应用与书签]")'), true, 'bookmarks share the app layout');
|
||||
await until(() => root.getBounds().width < 800, true, 'bookmark sidebar resizes only the webpage viewport');
|
||||
await click('aside [aria-label="添加应用"]');
|
||||
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'bookmark editor');
|
||||
await input('aside input[maxlength="120"]', 'Saved acceptance app');
|
||||
await input('aside input[type="url"]', url);
|
||||
await click('aside [data-section="browser-icon-editor"] button:nth-child(3)');
|
||||
await run(`(() => {
|
||||
const canvas=document.createElement('canvas'); canvas.width=640; canvas.height=320;
|
||||
const context=canvas.getContext('2d'); context.fillStyle='#247969'; context.fillRect(0,0,640,320);
|
||||
context.fillStyle='white'; context.font='bold 180px sans-serif'; context.fillText('N',245,225);
|
||||
const source=atob(canvas.toDataURL('image/png').split(',')[1]);
|
||||
const bytes=Uint8Array.from(source,c=>c.charCodeAt(0));
|
||||
const files=new DataTransfer(); files.items.add(new File([bytes],'team-app.png',{type:'image/png'}));
|
||||
const element=document.querySelector('aside input[type=file]'); element.files=files.files; element.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
})()`);
|
||||
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'chosen image has a real preview');
|
||||
assert.equal(await run('document.querySelector("aside input[type=file]").classList.contains("hidden")'), true, 'native file picker chrome is hidden');
|
||||
assert.equal(await run('document.querySelector("aside button[type=submit]").disabled'), false, 'processed image can be saved');
|
||||
const imageSize = await run(`new Promise(resolve=>{const image=new Image();image.onload=()=>resolve([image.width,image.height]);image.src=document.querySelector('aside img[alt=图标预览]').src;})`);
|
||||
assert.deepEqual(imageSize, [96, 48], 'large images are resized proportionally');
|
||||
await click('aside [aria-label="移除图片"]');
|
||||
await until(() => run('document.querySelector("aside button[type=submit]").disabled'), true, 'an empty image choice cannot be saved');
|
||||
await click('aside [data-section="browser-icon-editor"] button:nth-child(2)');
|
||||
await input('aside [aria-label="图标文字"]', 'N');
|
||||
await click('aside label:has(input[value="all-hosts"])');
|
||||
const editorTextScreenshot = path.join(userData, 'bookmark-editor-text.png');
|
||||
await capture(editorTextScreenshot, 'aside');
|
||||
await click('aside [data-section="browser-icon-editor"] button:nth-child(3)');
|
||||
await run(`(() => {
|
||||
const canvas=document.createElement('canvas');canvas.width=96;canvas.height=96;
|
||||
const context=canvas.getContext('2d');context.fillStyle='#247969';context.fillRect(0,0,96,96);context.fillStyle='white';context.font='bold 58px sans-serif';context.fillText('N',25,69);
|
||||
const source=atob(canvas.toDataURL().split(',')[1]); const files=new DataTransfer();
|
||||
files.items.add(new File([Uint8Array.from(source,c=>c.charCodeAt(0))],'new-icon.png',{type:'image/png'}));
|
||||
document.querySelector('aside input[type=file]').parentElement.dispatchEvent(new DragEvent('drop',{bubbles:true,dataTransfer:files}));
|
||||
})()`);
|
||||
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'a dropped replacement image loads');
|
||||
await run(`(() => {
|
||||
const files=new DataTransfer();files.items.add(new File(['not an image'],'invalid.txt',{type:'text/plain'}));
|
||||
const element=document.querySelector('aside input[type=file]');element.files=files.files;element.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
})()`);
|
||||
await until(() => run('!!document.querySelector("aside [role=alert]")'), true, 'unsupported files show an inline explanation');
|
||||
assert.equal(await run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'a failed replacement keeps the previous image');
|
||||
for (const [source, expected] of [["new Uint8Array(6*1024*1024)", '图片太大'], ["'broken PNG'", '无法读取']]) {
|
||||
await run(`(() => {const files=new DataTransfer();files.items.add(new File([${source}],'bad.png',{type:'image/png'}));const input=document.querySelector('aside input[type=file]');input.files=files.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await until(() => run(`document.querySelector('aside [role=alert]')?.textContent.includes(${JSON.stringify(expected)})`), true, 'invalid image has a specific inline explanation');
|
||||
}
|
||||
await run('document.querySelector("aside form").requestSubmit()');
|
||||
await until(() => run('!!Array.from(document.querySelectorAll("aside button")).find(b=>b.textContent.includes("Saved acceptance app"))'), true, 'saved bookmark is shown');
|
||||
await click('aside [aria-label="编辑"]');
|
||||
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'the saved image is restored when editing');
|
||||
assert.equal(await run('document.querySelector("aside input[value=all-hosts]").checked'), true, 'scope survives saving');
|
||||
const editorScreenshot=path.join(userData,'bookmark-editor-image.png');
|
||||
await capture(editorScreenshot, 'aside');
|
||||
for (const width of [400, 1000]) {
|
||||
owner.setSize(width, 800); await wait(150);
|
||||
assert.equal(await run('document.querySelector("aside").scrollWidth <= document.querySelector("aside").clientWidth'), true, 'the editor fits a narrow panel');
|
||||
await click('aside button[type=submit]');
|
||||
await until(() => run('!document.querySelector("aside input[type=url]")'), true, 'footer remains reachable after resizing');
|
||||
await click('aside [aria-label="编辑"]');
|
||||
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'reopen after resizing');
|
||||
}
|
||||
await click('aside [aria-label="关闭"]');
|
||||
await click('[role=tab]:nth-child(2)');
|
||||
await until(() => owner.contentView.children.includes(root), false, 'returning to the default tab removes the native input blocker');
|
||||
await click('[data-section=host-browser-panel] button');
|
||||
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'default-tab Add app opens the editor');
|
||||
await click('aside [aria-label="关闭"]');
|
||||
await click(`[data-section=host-browser-panel] button[title="${url}"]`);
|
||||
await until(() => manager.getPanelState(event, browserId).tabs.find(tab => tab.id === manager.getPanelState(event, browserId).activeTabId)?.url, url, 'default-tab bookmark opens its website');
|
||||
await until(() => owner.contentView.children.includes(root), true, 'bookmark navigation attaches the website again');
|
||||
await click('[aria-label="下载"]');
|
||||
await until(() => run('document.body.textContent.includes("暂无下载")'), true, 'downloads use the app popover');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
fs.writeFileSync(path.join(downloadsPath, 'report.txt'), 'Keep the existing file');
|
||||
const activePage = root.children[0].webContents;
|
||||
await activePage.executeJavaScript("document.getElementById('download').click()");
|
||||
await until(() => run('document.body.textContent.includes("report (1).txt") && document.body.textContent.includes("已完成")'), true, 'a website download saves immediately and opens its progress list');
|
||||
await until(() => !owner.contentView.children.includes(root), true, 'the automatic download list is above the native webpage before interacting');
|
||||
assert.equal(fs.readFileSync(path.join(downloadsPath, 'report (1).txt'), 'utf8'), 'Netcatty automatic download\n');
|
||||
assert.equal(fs.readFileSync(path.join(downloadsPath, 'report.txt'), 'utf8'), 'Keep the existing file');
|
||||
await click('[aria-label="更多操作"]');
|
||||
await until(() => run('Array.from(document.querySelectorAll("button")).some(button => button.textContent === "另存为…")'), true, 'download actions menu opens');
|
||||
await run('Array.from(document.querySelectorAll("button")).find(button => button.textContent === "另存为…").click()');
|
||||
await until(() => fs.existsSync(savedCopy), true, 'Save as writes a separate copy');
|
||||
assert.equal(fs.readFileSync(savedCopy, 'utf8'), 'Netcatty automatic download\n');
|
||||
assert.equal(fs.existsSync(path.join(downloadsPath, 'report (1).txt')), true, 'Save as retains the original downloaded file');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await click('[title="在文件夹中显示"]');
|
||||
assert.deepEqual(shellActions.at(-1), ['reveal', path.join(downloadsPath, 'report (1).txt')]);
|
||||
await click('[aria-label="打开下载文件夹"]');
|
||||
assert.deepEqual(shellActions.at(-1), ['open', downloadsPath]);
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await activePage.executeJavaScript("document.getElementById('download').click()");
|
||||
await until(() => run('document.body.textContent.includes("report (2).txt") && document.querySelectorAll("[data-download-id]").length === 2'), true, 'a second download also gets a new name and reopens the list');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
activePage.downloadURL(`${url}slow.bin`);
|
||||
await until(() => run('!!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'progress is visible while the server is still sending the file');
|
||||
await until(() => !owner.contentView.children.includes(root), true, 'the progress popover has acquired the webpage surface');
|
||||
await click('[aria-label="下载"]');
|
||||
await until(() => run('!!document.querySelector("[aria-label=取消下载]")'), false, 'the download toggle closes its progress list');
|
||||
await wait(350);
|
||||
assert.equal(await run('!!document.querySelector("[aria-label=取消下载]")'), false, 'progress updates do not reopen a dismissed list');
|
||||
await click('[aria-label="下载"]');
|
||||
await click('[aria-label="取消下载"]');
|
||||
await until(() => run('document.body.textContent.includes("已取消")'), true, 'download can be cancelled from the list');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await activePage.executeJavaScript("(() => { const a=document.createElement('a');a.href='/inline.pdf';document.body.append(a);a.click();a.remove(); })()");
|
||||
await until(() => run('document.body.textContent.includes("慢速报告.pdf") && !!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'inline PDF navigation opens progress before the response finishes');
|
||||
assert.equal(pendingPdf.writableEnded, false, 'the server has not sent the rest of the PDF');
|
||||
await until(() => run('document.body.textContent.includes("64.0 KB")'), true, 'received PDF bytes are visible while its body is held open');
|
||||
const pdfProgressScreenshot = path.join(userData, 'inline-pdf-progress.png');
|
||||
await capture(pdfProgressScreenshot);
|
||||
await click('[aria-label="取消下载"]');
|
||||
await until(() => pendingPdf.destroyed, true, 'cancelling an inline PDF stops the network transfer');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
pendingPdf = undefined;
|
||||
await input('[aria-label="输入网址"]', `${url}inline.pdf`);
|
||||
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
|
||||
await until(() => !!pendingPdf && !pendingPdf.destroyed, true, 'another inline PDF starts');
|
||||
await until(() => run('!!document.querySelector("[aria-label=取消下载]")'), true, 'another PDF also opens progress');
|
||||
pendingPdf.end(pdfBody.subarray(65536));
|
||||
await until(() => run('Array.from(document.querySelectorAll("[data-download-id]")).some(row => row.textContent.includes("慢速报告.pdf") && row.textContent.includes("已完成"))'), true, 'PDF progress becomes completed after the response finishes');
|
||||
assert.deepEqual(fs.readFileSync(path.join(downloadsPath, '慢速报告.pdf')), pdfBody, 'the PDF is saved without changing its contents');
|
||||
assert.equal(await run('document.body.textContent.includes("ERR_FAILED")'), false, 'address-bar downloads do not show a false page error');
|
||||
const downloadScreenshot=path.join(userData,'automatic-downloads.png');
|
||||
await capture(downloadScreenshot);
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
await input('[aria-label="输入网址"]', `${url}slow-page`);
|
||||
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
|
||||
await until(() => !!pendingNavigation, true, 'slow navigation is waiting for its response to finish');
|
||||
await click('[aria-label="新标签页"]');
|
||||
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab receives input focus while the previous page is loading');
|
||||
await input('[aria-label="输入网址"]', 'keep my address draft');
|
||||
pendingNavigation.end();
|
||||
await until(() => !activePage.isLoading(), true, 'the background page finishes loading');
|
||||
await wait(100);
|
||||
assert.equal(owner.webContents.isFocused(), true, 'background navigation must not steal the new tab input focus');
|
||||
assert.equal(await run('document.activeElement.getAttribute("aria-label")'), '输入网址');
|
||||
assert.equal(await run('document.querySelector("[aria-label=输入网址]").value'), 'keep my address draft');
|
||||
await click('[role=tab]:nth-child(2)');
|
||||
await until(() => owner.contentView.children.includes(root), true, 'return to the loaded page');
|
||||
await click('#visibility');
|
||||
await until(() => owner.contentView.children.includes(root), false, 'hidden terminal hides only its page');
|
||||
await click('#visibility');
|
||||
await until(() => owner.contentView.children.includes(root), true, 'returning terminal restores page');
|
||||
for (const width of [400, 1000]) {
|
||||
owner.setSize(width, 800); await wait(150);
|
||||
assert.equal(await run('document.documentElement.scrollWidth <= innerWidth'), true, 'no document overflow at narrow and wide sizes');
|
||||
}
|
||||
for (const locale of ['en', 'zh-TW', 'es', 'ru', 'zh-CN']) {
|
||||
await run(`(() => { const el=document.querySelector('select'); const setter=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set; setter.call(el,${JSON.stringify(locale)}); el.dispatchEvent(new Event('change',{bubbles:true})); })()`);
|
||||
await wait(50);
|
||||
assert.equal(await run('document.body.textContent.includes("terminal.browser.")'), false, 'no untranslated browser labels');
|
||||
}
|
||||
const screenshot = path.join(userData, 'unified-browser.png');
|
||||
fs.writeFileSync(screenshot, (await owner.webContents.capturePage()).toPNG());
|
||||
assert.deepEqual(errors, [], 'no renderer errors');
|
||||
console.log(JSON.stringify({ ok: true, rendererChrome: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
|
||||
}
|
||||
app.whenReady().then(main).catch(async (error) => { console.error(error); if (owner && !owner.isDestroyed()) { const screenshot=path.join(userData,'failure.png'); fs.writeFileSync(screenshot,(await owner.webContents.capturePage()).toPNG()); console.error(screenshot); } process.exitCode = 1; }).finally(async () => {
|
||||
clearTimeout(watchdog);
|
||||
manager?.closeAll(); if (owner && !owner.isDestroyed()) owner.destroy();
|
||||
if (site) { site.closeAllConnections(); site.close(); }
|
||||
await vite?.close();
|
||||
// Keep the isolated screenshot and profile under Netcatty's managed temp directory for inspection.
|
||||
app.exit(process.exitCode || 0);
|
||||
});
|
||||
@@ -5,33 +5,18 @@ const fs = require("node:fs");
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
const { createBookmarkStore } = require("./bookmarkStore.cjs");
|
||||
const { createDownloadStore } = require("./downloadStore.cjs");
|
||||
const { reserveDownloadFile } = require("./downloadFile.cjs");
|
||||
const { createSiteIconStore, iconOrigin, validImageDataUrl } = require("./siteIconStore.cjs");
|
||||
const { windowsFramelessContentChromeOptions } = require("../bridges/windowManager/windowsWindowChrome.cjs");
|
||||
|
||||
const DEAD_PROXY = "socks5://127.0.0.1:9";
|
||||
const CHROME_HEIGHT = 80;
|
||||
const chromeHeight = (record) => Math.round((record.embedded ? 0 : CHROME_HEIGHT) * (record.zoom || 1));
|
||||
const EMBEDDED_CHROME_HEIGHT = 64;
|
||||
const chromeHeight = (record) => Math.round((record.embedded ? EMBEDDED_CHROME_HEIGHT : CHROME_HEIGHT) * (record.zoom || 1));
|
||||
const MAX_TABS = 32;
|
||||
const DOWNLOAD_PANEL_WIDTH = 372;
|
||||
const ALLOWED_PAGE_SCHEMES = new Set(["http:", "https:", "ws:", "wss:", "blob:", "data:"]);
|
||||
const THEME_TOKEN_NAMES = ["background", "foreground", "secondary", "muted", "muted-foreground", "border", "primary", "primary-foreground"];
|
||||
|
||||
function pdfDownloadHeaders({ resourceType, statusCode, responseHeaders }) {
|
||||
// The page allowlist deliberately excludes Chromium's extension-based PDF
|
||||
// viewer. Hand PDF navigations to the download manager at response headers,
|
||||
// before that viewer can consume/cache the body outside download tracking.
|
||||
if (!["mainFrame", "subFrame"].includes(resourceType) || statusCode < 200 || statusCode >= 300 || !responseHeaders) return;
|
||||
const typeKey = Object.keys(responseHeaders).find((key) => key.toLowerCase() === "content-type");
|
||||
if (responseHeaders[typeKey]?.[0]?.split(";", 1)[0].trim().toLowerCase() !== "application/pdf") return;
|
||||
const dispositionKey = Object.keys(responseHeaders).find((key) => key.toLowerCase() === "content-disposition") || "Content-Disposition";
|
||||
const disposition = responseHeaders[dispositionKey]?.[0] || "";
|
||||
if (/^\s*attachment\s*(;|$)/i.test(disposition)) return;
|
||||
// Keep the site's filename / filename* parameters, including localized names.
|
||||
const parameters = disposition.indexOf(";");
|
||||
return { ...responseHeaders, [dispositionKey]: [`attachment${parameters < 0 ? "" : disposition.slice(parameters)}`] };
|
||||
}
|
||||
|
||||
function validThemeTokens(input) {
|
||||
const result = {};
|
||||
for (const name of THEME_TOKEN_NAMES) {
|
||||
@@ -89,7 +74,7 @@ function normalizeBrowserUrl(input) {
|
||||
}
|
||||
|
||||
function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
const { BrowserWindow, WebContentsView, session, ipcMain, shell, dialog } = electron;
|
||||
const { BrowserWindow, WebContentsView, session, ipcMain, shell } = electron;
|
||||
const windows = new Map();
|
||||
const panelLayouts = new Map();
|
||||
const byChromeId = new Map();
|
||||
@@ -98,7 +83,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
const downloadStore = createDownloadStore(app);
|
||||
const bookmarkStore = createBookmarkStore(app, () => {
|
||||
for (const record of windows.values()) {
|
||||
if (record.embedded && !record.chromeContents.isDestroyed()) panelEvent(record, { type: "bookmarks" });
|
||||
if (record.embedded && !record.chromeContents.isDestroyed()) record.chromeContents.send("netcatty:host-browser:bookmarks-changed");
|
||||
}
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
|
||||
@@ -106,21 +91,16 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
}
|
||||
}
|
||||
});
|
||||
const panelEvent = (record, event) => {
|
||||
if (!record.sourceWebContents.isDestroyed()) record.sourceWebContents.send("netcatty:host-browser:panel-event", { browserId: record.id, ...event });
|
||||
};
|
||||
const send = (record) => {
|
||||
if (record.win.isDestroyed() || record.chromeContents.isDestroyed()) return;
|
||||
if (record.embedded) panelEvent(record, { type: "state", state: snapshot(record) });
|
||||
else record.chromeContents.send("netcatty:host-browser:state", snapshot(record));
|
||||
record.chromeContents.send("netcatty:host-browser:state", snapshot(record));
|
||||
};
|
||||
const notifyDownloads = (record, startedId) => {
|
||||
const notifyDownloads = (record) => {
|
||||
const downloads = downloadStore.list(record.hostId);
|
||||
for (const target of windows.values()) {
|
||||
if (target.hostId !== record.hostId || target.win.isDestroyed() || target.chromeContents.isDestroyed()) continue;
|
||||
if (target.downloadsPanelOpen) positionDownloadsView(target);
|
||||
if (target.embedded) panelEvent(target, { type: "downloads", downloads, startedId: target === record ? startedId : undefined });
|
||||
else target.chromeContents.send("netcatty:host-browser:downloads-changed", downloads);
|
||||
target.chromeContents.send("netcatty:host-browser:downloads-changed", downloads);
|
||||
if (target.downloadsView && !target.downloadsView.webContents.isDestroyed()) {
|
||||
target.downloadsView.webContents.send("netcatty:host-browser:downloads-changed", downloads);
|
||||
}
|
||||
@@ -146,10 +126,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
});
|
||||
const notifySiteIcon = (record) => {
|
||||
send(record);
|
||||
if (!record.win.isDestroyed()) {
|
||||
if (record.embedded) panelEvent(record, { type: "bookmarks" });
|
||||
else record.chromeContents.send("netcatty:host-browser:site-icons-changed");
|
||||
}
|
||||
if (!record.win.isDestroyed()) record.chromeContents.send("netcatty:host-browser:site-icons-changed");
|
||||
};
|
||||
const updateFavicon = async (record, tab, urls) => {
|
||||
if (!tab.url || record.route !== "connected") return;
|
||||
@@ -190,7 +167,6 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
send(record);
|
||||
};
|
||||
const setDownloadsPanel = async (record, open) => {
|
||||
if (record.embedded) return;
|
||||
if (!open) { closeDownloadsPanel(record); return; }
|
||||
if (!record.downloadsView) {
|
||||
const view = new WebContentsView({ webPreferences: {
|
||||
@@ -240,18 +216,6 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
try { record.container.removeChildView(record.downloadsView); } catch {}
|
||||
record.container.addChildView(record.downloadsView);
|
||||
}
|
||||
if (record.embedded) {
|
||||
const attached = record.win.contentView.children.includes(record.container);
|
||||
// An empty native View still intercepts mouse input on macOS. The
|
||||
// renderer owns the default tab and connection screens, so attach this
|
||||
// container only while it contains a website.
|
||||
if (record.container.children.length) {
|
||||
if (!attached) record.win.contentView.addChildView(record.container);
|
||||
} else if (attached) {
|
||||
record.win.contentView.removeChildView(record.container);
|
||||
record.sourceWebContents.focus();
|
||||
}
|
||||
}
|
||||
send(record);
|
||||
};
|
||||
const refreshTab = (record, tab) => {
|
||||
@@ -287,11 +251,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
const type = { t: "new-tab", w: "close-tab", r: "reload", l: "focus-address" }[key];
|
||||
if (!type) return;
|
||||
event.preventDefault();
|
||||
if (type === "focus-address" || type === "new-tab") record.chromeContents.focus();
|
||||
if (!record.win.isDestroyed()) {
|
||||
if (record.embedded) panelEvent(record, { type: "shortcut", shortcut: { type, tabId: tab.id } });
|
||||
else record.chromeContents.send("netcatty:host-browser:shortcut", { type, tabId: tab.id });
|
||||
}
|
||||
if (!record.win.isDestroyed()) record.chromeContents.send("netcatty:host-browser:shortcut", { type, tabId: tab.id });
|
||||
});
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
try { addTab(record, normalizeBrowserUrl(url)); } catch {}
|
||||
@@ -353,7 +313,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
record.sourceWebContents.off("destroyed", record.onOwnerClosed);
|
||||
if (record.embedded) {
|
||||
try { record.win.contentView.removeChildView(record.container); } catch {}
|
||||
if (record.chromeView) { try { record.chromeContents.close(); } catch {} }
|
||||
try { record.chromeContents.close(); } catch {}
|
||||
}
|
||||
byChromeId.delete(record.chromeId);
|
||||
if (record.downloadsView) {
|
||||
@@ -460,7 +420,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
record.themeTokens = validThemeTokens(layout.themeTokens);
|
||||
record.downloadsView?.webContents.send("netcatty:host-browser:theme", { theme: record.theme, themeTokens: record.themeTokens });
|
||||
}
|
||||
const visible = layout?.sender === record.sourceWebContents && layout.visible === true && !layout.occluded;
|
||||
const visible = layout?.sender === record.sourceWebContents && layout.visible === true;
|
||||
const attached = record.win.contentView.children.includes(record.container);
|
||||
if (!visible) {
|
||||
if (attached) {
|
||||
@@ -485,28 +445,13 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
return;
|
||||
}
|
||||
record.container.setBounds(record.bounds);
|
||||
record.chromeView.setBounds({ x: 0, y: 0, width, height });
|
||||
record.chromeContents.setZoomFactor(zoom);
|
||||
for (const tab of record.tabs.values()) tab.view.webContents.setZoomFactor(zoom);
|
||||
if (!attached) record.win.contentView.addChildView(record.container);
|
||||
resizeView(record);
|
||||
displayTab(record);
|
||||
};
|
||||
const capturePanelLayers = (record) => {
|
||||
if (record.panelSnapshotPromise) return record.panelSnapshotPromise;
|
||||
const zoom = record.zoom || 1;
|
||||
const views = record.container.children.filter((view) => view instanceof WebContentsView);
|
||||
const operation = Promise.all(views.map(async (view) => {
|
||||
const bounds = view.getBounds();
|
||||
const image = await view.webContents.capturePage();
|
||||
if (image.isEmpty()) return null;
|
||||
return { image: image.toDataURL(), bounds: Object.fromEntries(
|
||||
Object.entries(bounds).map(([key, value]) => [key, value / zoom]),
|
||||
) };
|
||||
})).then((layers) => layers.filter(Boolean)).catch(() => []);
|
||||
record.panelSnapshotPromise = operation;
|
||||
void operation.finally(() => {
|
||||
if (record.panelSnapshotPromise === operation) record.panelSnapshotPromise = null;
|
||||
});
|
||||
return operation;
|
||||
};
|
||||
const updatePanelLayout = (event, input) => {
|
||||
const owner = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!owner || owner.webContents !== event.sender) throw new Error("Untrusted browser panel owner");
|
||||
@@ -520,18 +465,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
const bounds = input.bounds;
|
||||
if (input.visible && (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite)
|
||||
|| bounds.width < 0 || bounds.height < 0)) throw new Error("Invalid browser panel bounds");
|
||||
const layout = { sender: event.sender, visible: input.visible === true, occluded: input.occluded === true, bounds, pendingUrls: previous?.pendingUrls ?? [],
|
||||
theme: input.theme === "light" || input.theme === "dark" ? input.theme : undefined, themeTokens: validThemeTokens(input.themeTokens) };
|
||||
panelLayouts.set(id, layout);
|
||||
if (record && layout.visible && layout.occluded) {
|
||||
return capturePanelLayers(record).then((layers) => {
|
||||
// A menu may close, the pane may move or the owner may disappear while
|
||||
// capture is pending. Never hide a newer layout or return stale pixels.
|
||||
if (panelLayouts.get(id) !== layout || windows.get(id) !== record || owner.isDestroyed()) return false;
|
||||
applyPanelLayout(record);
|
||||
return { layers };
|
||||
});
|
||||
}
|
||||
panelLayouts.set(id, { sender: event.sender, visible: input.visible === true, bounds, pendingUrls: previous?.pendingUrls ?? [],
|
||||
theme: input.theme === "light" || input.theme === "dark" ? input.theme : undefined, themeTokens: validThemeTokens(input.themeTokens) });
|
||||
if (record) applyPanelLayout(record);
|
||||
return true;
|
||||
};
|
||||
@@ -608,30 +543,18 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
themeTokens: validThemeTokens(input?.themeTokens),
|
||||
pageSession, route: "connecting", error: null, panelOpen: false, overlayOpen: false,
|
||||
tunnelId: null, connectionOptions: input.connectionOptions, sourceWebContents: event.sender,
|
||||
routeGeneration: 0, proxyQueue: Promise.resolve(), tabs: new Map(), downloads: new Map(), navigations: new Map(),
|
||||
routeGeneration: 0, proxyQueue: Promise.resolve(), tabs: new Map(), downloads: new Map(),
|
||||
downloadsView: null, downloadsPanelOpen: false, activeTabId: null, win: null };
|
||||
record.downloadHandler = (event, item, contents) => {
|
||||
const ownedPage = [...record.tabs.values()].some((tab) => tab.view.webContents === contents);
|
||||
if (record.route !== "connected" || !ownedPage) { event.preventDefault(); return; }
|
||||
const id = randomUUID();
|
||||
const filename = path.basename(String(item.getFilename() || "download").replace(/\\/g, "/")) || "download";
|
||||
let destination;
|
||||
try {
|
||||
destination = reserveDownloadFile(app.getPath("downloads"), filename);
|
||||
item.setSavePath(destination.savePath);
|
||||
} catch {
|
||||
destination?.cleanupEmpty();
|
||||
event.preventDefault();
|
||||
downloadStore.upsert({ id, hostId, filename, url: item.getURL(), savePath: "", status: "interrupted",
|
||||
received: 0, total: item.getTotalBytes(), startedAt: Date.now(), endedAt: Date.now() });
|
||||
notifyDownloads(record, id);
|
||||
return;
|
||||
}
|
||||
item.setSaveDialogOptions({ defaultPath: path.join(app.getPath("downloads"), filename) });
|
||||
record.downloads.set(id, item);
|
||||
downloadStore.upsert({ id, hostId, filename: path.basename(destination.savePath), url: item.getURL(), savePath: destination.savePath,
|
||||
status: "progressing", received: 0, total: item.getTotalBytes(), startedAt: Date.now() });
|
||||
notifyDownloads(record, id);
|
||||
if (!record.embedded) void setDownloadsPanel(record, true).catch(() => {});
|
||||
downloadStore.upsert({ id, hostId, filename, url: item.getURL(), savePath: "",
|
||||
status: "waiting", received: 0, total: item.getTotalBytes(), startedAt: Date.now() });
|
||||
notifyDownloads(record);
|
||||
let lastProgressAt = 0;
|
||||
item.on("updated", (_updateEvent, status) => {
|
||||
const now = Date.now();
|
||||
@@ -646,7 +569,6 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
});
|
||||
item.once("done", (_doneEvent, status) => {
|
||||
record.downloads.delete(id);
|
||||
if (status !== "completed") destination.cleanupEmpty();
|
||||
const previous = downloadStore.get(hostId, id);
|
||||
if (!previous) return;
|
||||
const savePath = item.getSavePath() || previous.savePath;
|
||||
@@ -659,20 +581,12 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
pageSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
let allowed = false;
|
||||
try { allowed = record.route === "connected" && ALLOWED_PAGE_SCHEMES.has(new URL(details.url).protocol); } catch {}
|
||||
const navigation = record.navigations.get(details.webContentsId);
|
||||
if (allowed && details.resourceType === "mainFrame" && navigation?.url === details.url) navigation.requestId = details.id;
|
||||
callback({ cancel: !allowed });
|
||||
});
|
||||
pageSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
const responseHeaders = pdfDownloadHeaders(details);
|
||||
const navigation = record.navigations.get(details.webContentsId);
|
||||
const headers = responseHeaders || details.responseHeaders || {};
|
||||
if (navigation?.requestId === details.id && details.resourceType === "mainFrame" && details.statusCode >= 200 && details.statusCode < 300
|
||||
&& Object.entries(headers).some(([key, values]) => key.toLowerCase() === "content-disposition" && /^\s*attachment\s*(;|$)/i.test(values[0]))) {
|
||||
navigation.downloading = true;
|
||||
}
|
||||
callback(responseHeaders ? { responseHeaders } : {});
|
||||
});
|
||||
const chromeOptions = { webPreferences: {
|
||||
preload: path.join(__dirname, "browserPreload.cjs"),
|
||||
sandbox: true, contextIsolation: true, nodeIntegration: false,
|
||||
} };
|
||||
const win = embedded ? owner : new BrowserWindow({
|
||||
title: "Browser",
|
||||
width: 1100, height: 760, minWidth: 720, minHeight: 480,
|
||||
@@ -688,11 +602,12 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
});
|
||||
record.win = win;
|
||||
record.container = embedded ? new electron.View() : win.contentView;
|
||||
record.chromeView = null;
|
||||
record.chromeContents = win.webContents;
|
||||
record.chromeView = embedded ? new WebContentsView(chromeOptions) : null;
|
||||
record.chromeContents = record.chromeView?.webContents || win.webContents;
|
||||
if (embedded) record.container.addChildView(record.chromeView);
|
||||
record.chromeId = record.chromeContents.id;
|
||||
windows.set(id, record);
|
||||
if (!embedded) byChromeId.set(record.chromeId, record);
|
||||
byChromeId.set(record.chromeId, record);
|
||||
record.onResize = () => resizeView(record);
|
||||
record.onBlur = () => closeDownloadsPanel(record);
|
||||
record.onMaximize = () => send(record);
|
||||
@@ -703,12 +618,10 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
record.onOwnerClosed = () => closeRecord(record);
|
||||
win.on("closed", record.onOwnerClosed);
|
||||
event.sender.once("destroyed", record.onOwnerClosed);
|
||||
if (!embedded) {
|
||||
record.chromeContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
record.chromeContents.on("will-navigate", (navigationEvent) => navigationEvent.preventDefault());
|
||||
}
|
||||
record.chromeContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
record.chromeContents.on("will-navigate", (navigationEvent) => navigationEvent.preventDefault());
|
||||
try {
|
||||
if (!embedded) await record.chromeContents.loadFile(path.join(__dirname, "browser.html"));
|
||||
await record.chromeContents.loadFile(path.join(__dirname, "browser.html"));
|
||||
if (win.isDestroyed() || windows.get(id) !== record) throw new Error("Browser was closed before it opened");
|
||||
addTab(record);
|
||||
if (embedded) applyPanelLayout(record);
|
||||
@@ -728,19 +641,12 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
record.win.focus();
|
||||
return record.route === "connected";
|
||||
};
|
||||
const getOwned = (event, browserId) => {
|
||||
if (browserId !== undefined) {
|
||||
const owned = windows.get(browserId);
|
||||
if (!owned?.embedded || owned.sourceWebContents !== event.sender
|
||||
|| BrowserWindow.fromWebContents(event.sender)?.webContents !== event.sender) throw new Error("Untrusted browser control");
|
||||
return owned;
|
||||
}
|
||||
const getOwned = (event) => {
|
||||
const record = byChromeId.get(event.sender.id);
|
||||
if (!record || record.chromeContents !== event.sender) throw new Error("Untrusted browser control");
|
||||
return record;
|
||||
};
|
||||
const getBrowserOwned = (event, browserId) => {
|
||||
if (browserId !== undefined) return getOwned(event, browserId);
|
||||
const getBrowserOwned = (event) => {
|
||||
const chrome = byChromeId.get(event.sender.id);
|
||||
if (chrome && chrome.chromeContents === event.sender) return chrome;
|
||||
const downloads = byDownloadsId.get(event.sender.id);
|
||||
@@ -752,18 +658,17 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
ipcMain.handle("netcatty:host-browser:panel-url", openPanelUrl);
|
||||
ipcMain.handle("netcatty:host-browser:open", open);
|
||||
ipcMain.handle("netcatty:host-browser:focus", focus);
|
||||
const getPanelState = (event, browserId) => snapshot(getOwned(event, browserId));
|
||||
ipcMain.handle("netcatty:host-browser:snapshot", getPanelState);
|
||||
ipcMain.handle("netcatty:host-browser:downloads:list", (event, browserId) => {
|
||||
const record = getBrowserOwned(event, browserId);
|
||||
ipcMain.handle("netcatty:host-browser:snapshot", (event) => snapshot(getOwned(event)));
|
||||
ipcMain.handle("netcatty:host-browser:downloads:list", (event) => {
|
||||
const record = getBrowserOwned(event);
|
||||
return downloadStore.list(record.hostId);
|
||||
});
|
||||
ipcMain.handle("netcatty:host-browser:downloads:theme", (event) => {
|
||||
const record = getBrowserOwned(event);
|
||||
return { theme: record.theme, themeTokens: record.themeTokens };
|
||||
});
|
||||
ipcMain.handle("netcatty:host-browser:downloads:command", async (event, input, browserId) => {
|
||||
const record = getBrowserOwned(event, browserId);
|
||||
ipcMain.handle("netcatty:host-browser:downloads:command", async (event, input) => {
|
||||
const record = getBrowserOwned(event);
|
||||
const item = downloadStore.get(record.hostId, input?.id);
|
||||
switch (input?.type) {
|
||||
case "close": closeDownloadsPanel(record); return true;
|
||||
@@ -784,18 +689,11 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
notifyDownloads(record);
|
||||
return true;
|
||||
case "open":
|
||||
case "save-as":
|
||||
case "reveal": {
|
||||
if (!item || item.status !== "completed" || !item.savePath || !fs.existsSync(item.savePath)) {
|
||||
throw new Error("File moved or deleted");
|
||||
}
|
||||
if (input.type === "save-as") {
|
||||
const result = await (dialog || require("electron").dialog).showSaveDialog(record.win, {
|
||||
defaultPath: item.savePath, properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return false;
|
||||
if (path.resolve(result.filePath) !== path.resolve(item.savePath)) await fs.promises.copyFile(item.savePath, result.filePath);
|
||||
} else if (input.type === "reveal") (shell || require("electron").shell).showItemInFolder(item.savePath);
|
||||
if (input.type === "reveal") (shell || require("electron").shell).showItemInFolder(item.savePath);
|
||||
else {
|
||||
const error = await (shell || require("electron").shell).openPath(item.savePath);
|
||||
if (error) throw new Error(error);
|
||||
@@ -805,8 +703,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
default: throw new Error("Unknown download action");
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:host-browser:bookmarks:list", (event, browserId) => {
|
||||
const record = getOwned(event, browserId);
|
||||
ipcMain.handle("netcatty:host-browser:bookmarks:list", (event) => {
|
||||
const record = getOwned(event);
|
||||
return bookmarkStore.list().filter((item) => item.scope === "all-hosts" || item.hostId === record.hostId)
|
||||
.map((item) => {
|
||||
const siteIcon = siteIconStore.get(record.hostId, item.url);
|
||||
@@ -814,16 +712,16 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
siteIconGlyph: siteIcon?.glyph || null, siteIconColor: siteIcon?.color || null };
|
||||
});
|
||||
});
|
||||
ipcMain.handle("netcatty:host-browser:bookmarks:upsert", (event, input, browserId) => {
|
||||
const record = getOwned(event, browserId);
|
||||
ipcMain.handle("netcatty:host-browser:bookmarks:upsert", (event, input) => {
|
||||
const record = getOwned(event);
|
||||
if (input?.id) {
|
||||
const previous = bookmarkStore.list().find((item) => item.id === input.id);
|
||||
if (previous && previous.scope === "host" && previous.hostId !== record.hostId) throw new Error("Bookmark belongs to another host");
|
||||
}
|
||||
return bookmarkStore.upsert({ ...input, hostId: input?.scope === "all-hosts" ? null : record.hostId });
|
||||
});
|
||||
ipcMain.handle("netcatty:host-browser:bookmarks:remove", (event, id, browserId) => {
|
||||
const record = getOwned(event, browserId);
|
||||
ipcMain.handle("netcatty:host-browser:bookmarks:remove", (event, id) => {
|
||||
const record = getOwned(event);
|
||||
const previous = bookmarkStore.list().find((item) => item.id === id);
|
||||
if (!previous || (previous.scope === "host" && previous.hostId !== record.hostId)) return false;
|
||||
bookmarkStore.remove(id);
|
||||
@@ -838,8 +736,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
bookmarkStore.apply(items);
|
||||
return true;
|
||||
});
|
||||
const runCommand = async (event, command, browserId) => {
|
||||
const record = getOwned(event, browserId);
|
||||
ipcMain.handle("netcatty:host-browser:command", async (event, command) => {
|
||||
const record = getOwned(event);
|
||||
if (command?.type?.startsWith("window-") && record.embedded) return snapshot(record);
|
||||
if (command?.type === "window-close") { record.win.close(); return null; }
|
||||
const tab = record.tabs.get(command?.tabId || record.activeTabId);
|
||||
@@ -883,7 +781,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activate": if (tab) { record.activeTabId = tab.id; displayTab(record); if (record.visible && tab.url) tab.view.webContents.focus(); } break;
|
||||
case "activate": if (tab) { record.activeTabId = tab.id; displayTab(record); } break;
|
||||
case "close-tab":
|
||||
if (tab) {
|
||||
const tabs = [...record.tabs.values()];
|
||||
@@ -901,20 +799,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
if (tab) {
|
||||
tab.url = normalizeBrowserUrl(command.url);
|
||||
displayTab(record);
|
||||
const contents = tab.view.webContents;
|
||||
const contentsId = contents.id;
|
||||
const url = tab.url;
|
||||
const navigation = { url, downloading: false };
|
||||
record.navigations.set(contentsId, navigation);
|
||||
if (record.visible && record.activeTabId === tab.id && !record.overlayOpen) contents.focus();
|
||||
try { await contents.loadURL(url); }
|
||||
catch (error) {
|
||||
// Chromium rejects loadURL when a navigation becomes a download.
|
||||
// Response headers identify this request before will-download fires.
|
||||
if (!navigation.downloading || !["ERR_FAILED", "ERR_ABORTED"].includes(error.code)) throw error;
|
||||
} finally {
|
||||
if (record.navigations.get(contentsId) === navigation) record.navigations.delete(contentsId);
|
||||
}
|
||||
await tab.view.webContents.loadURL(tab.url);
|
||||
}
|
||||
break;
|
||||
case "back": if (tab && record.route === "connected") tab.view.webContents.navigationHistory?.goBack(); break;
|
||||
@@ -924,8 +809,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
default: throw new Error("Unknown browser command");
|
||||
}
|
||||
return snapshot(record);
|
||||
};
|
||||
ipcMain.handle("netcatty:host-browser:command", runCommand);
|
||||
});
|
||||
const unsubscribeTunnelStatus = portForwardingBridge.onTunnelStatus((event) => {
|
||||
for (const record of windows.values()) {
|
||||
if (record.tunnelId === event.tunnelId && ["inactive", "error"].includes(event.status)) {
|
||||
@@ -933,10 +817,10 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
}
|
||||
}
|
||||
});
|
||||
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, getPanelState, runCommand, closeAll: () => {
|
||||
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, closeAll: () => {
|
||||
unsubscribeTunnelStatus();
|
||||
for (const record of [...windows.values()]) { closeRecord(record); if (!record.embedded && !record.win.isDestroyed()) record.win.destroy(); }
|
||||
} };
|
||||
}
|
||||
|
||||
module.exports = { createHostBrowserManager, normalizeBrowserUrl, pdfDownloadHeaders };
|
||||
module.exports = { createHostBrowserManager, normalizeBrowserUrl };
|
||||
@@ -4,35 +4,7 @@ const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const vm = require("node:vm");
|
||||
const { normalizeBrowserUrl, pdfDownloadHeaders } = require("./hostBrowserManager.cjs");
|
||||
|
||||
test("PDF navigations download before the preview can buffer the response", () => {
|
||||
const headers = { "Content-Type": ["Application/PDF;charset=UTF-8"], "Content-Length": ["8933895"] };
|
||||
for (const resourceType of ["mainFrame", "subFrame"]) {
|
||||
assert.deepEqual(pdfDownloadHeaders({ resourceType, statusCode: 200, responseHeaders: headers }), {
|
||||
...headers, "Content-Disposition": ["attachment"],
|
||||
});
|
||||
}
|
||||
assert.equal(headers["Content-Disposition"], undefined, "the original response headers are not mutated");
|
||||
});
|
||||
|
||||
test("PDF downloads preserve the site's localized suggested filename", () => {
|
||||
const filename = "; filename=report.pdf; filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf";
|
||||
const headers = { "content-type": ["application/pdf"], "content-disposition": [`inline${filename}`] };
|
||||
assert.deepEqual(pdfDownloadHeaders({ resourceType: "mainFrame", statusCode: 200, responseHeaders: headers }), {
|
||||
...headers, "content-disposition": [`attachment${filename}`],
|
||||
});
|
||||
});
|
||||
|
||||
test("PDF policy leaves webpage requests, failed responses and explicit downloads alone", () => {
|
||||
const responseHeaders = { "content-type": ["application/pdf"] };
|
||||
const details = { resourceType: "mainFrame", statusCode: 200, responseHeaders };
|
||||
for (const resourceType of ["xhr", "object", "other", "script", "image"]) assert.equal(pdfDownloadHeaders({ ...details, resourceType }), undefined);
|
||||
for (const statusCode of [302, 404, 500]) assert.equal(pdfDownloadHeaders({ ...details, statusCode }), undefined);
|
||||
for (const mime of ["text/html", "image/png", "application/octet-stream"]) assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: { "content-type": [mime] } }), undefined);
|
||||
assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: { ...responseHeaders, "content-disposition": ["attachment; filename=report.pdf"] } }), undefined);
|
||||
assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: undefined }), undefined);
|
||||
});
|
||||
const { normalizeBrowserUrl } = require("./hostBrowserManager.cjs");
|
||||
|
||||
test("browser addresses accept bare localhost and domain ports", () => {
|
||||
assert.equal(normalizeBrowserUrl("localhost:3000"), "http://localhost:3000/");
|
||||
|
||||
@@ -1341,18 +1341,6 @@ function createPreloadApi(ctx) {
|
||||
focusHostBrowser: (hostId) => ipcRenderer.invoke("netcatty:host-browser:focus", hostId),
|
||||
setHostBrowserPanelLayout: (options) => ipcRenderer.invoke("netcatty:host-browser:panel-layout", options),
|
||||
closeHostBrowserPanel: (browserId) => ipcRenderer.invoke("netcatty:host-browser:panel-close", browserId),
|
||||
getHostBrowserPanelState: (browserId) => ipcRenderer.invoke("netcatty:host-browser:snapshot", browserId),
|
||||
commandHostBrowserPanel: (browserId, command) => ipcRenderer.invoke("netcatty:host-browser:command", command, browserId),
|
||||
listHostBrowserPanelBookmarks: (browserId) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:list", browserId),
|
||||
saveHostBrowserPanelBookmark: (browserId, bookmark) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:upsert", bookmark, browserId),
|
||||
removeHostBrowserPanelBookmark: (browserId, id) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:remove", id, browserId),
|
||||
listHostBrowserPanelDownloads: (browserId) => ipcRenderer.invoke("netcatty:host-browser:downloads:list", browserId),
|
||||
commandHostBrowserPanelDownload: (browserId, command) => ipcRenderer.invoke("netcatty:host-browser:downloads:command", command, browserId),
|
||||
onHostBrowserPanelEvent: (callback) => {
|
||||
const listener = (_event, value) => callback(value);
|
||||
ipcRenderer.on("netcatty:host-browser:panel-event", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:host-browser:panel-event", listener);
|
||||
},
|
||||
openHostBrowserUrl: (options) => ipcRenderer.invoke("netcatty:host-browser:panel-url", options),
|
||||
requestHostBrowserPanel: (request) => ipcRenderer.invoke("netcatty:host-browser:request-panel", request),
|
||||
onHostBrowserPanelRequest: (callback) => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface CattyStreamChunk {
|
||||
stepNumber?: number;
|
||||
}
|
||||
|
||||
const STEP_HANDLE_NOTICE_RE = /^(?:\[step \d+\] Tool output handles available:|\[Tool output handles available:)/;
|
||||
const STEP_HANDLE_NOTICE_RE = /^\[step \d+\] Tool output handles available:/;
|
||||
|
||||
function isStepHandleNoticeMessage(content: unknown): boolean {
|
||||
return typeof content === 'string' && STEP_HANDLE_NOTICE_RE.test(content);
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CattyTurnDriver } from './turnDrivers/cattyTurnDriver';
|
||||
import { ToolOutputStore } from './toolOutputStore';
|
||||
import { ToolResultDedup } from './toolResultDedup';
|
||||
import { SessionStateStore } from './sessionState';
|
||||
import type { CattyTurnInput, TurnDriverContext } from './turnDrivers/types';
|
||||
import type { AgentEvent } from './types';
|
||||
import type { AISession, ChatMessage, ProviderConfig } from '../types';
|
||||
|
||||
interface WireMessage {
|
||||
role: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
interface WireRequest {
|
||||
messages: WireMessage[];
|
||||
tools?: unknown[];
|
||||
system?: Array<{ type: string; text: string; cache_control?: unknown }>;
|
||||
}
|
||||
|
||||
type Reply = { tool: 'terminal_execute' | 'sftp_read_file' } | { text: string };
|
||||
|
||||
function fixture(t: test.TestContext, replies: Reply[], style: 'openai' | 'anthropic' = 'openai', terminalOutput = 'small output') {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalRaf = globalThis.requestAnimationFrame;
|
||||
const originalCancelRaf = globalThis.cancelAnimationFrame;
|
||||
t.after(() => {
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.requestAnimationFrame = originalRaf;
|
||||
globalThis.cancelAnimationFrame = originalCancelRaf;
|
||||
});
|
||||
globalThis.requestAnimationFrame = callback => setTimeout(() => callback(0), 0) as unknown as number;
|
||||
globalThis.cancelAnimationFrame = id => clearTimeout(id);
|
||||
|
||||
const requests: WireRequest[] = [];
|
||||
const urls: string[] = [];
|
||||
const events: AgentEvent[] = [];
|
||||
const messages: ChatMessage[] = [];
|
||||
const data = new Map<string, (value: string) => void>();
|
||||
const end = new Map<string, () => void>();
|
||||
const bridge = {
|
||||
aiSetChatSessionCancelled: async () => ({ ok: true }),
|
||||
aiMcpUpdateSessions: async () => undefined,
|
||||
aiExec: async () => ({ ok: true, stdout: terminalOutput, stderr: '', exitCode: 0 }),
|
||||
aiCapability: async (_method: string, params: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
path: params.path,
|
||||
content: Array.from({ length: 2000 }, (_, i) => `log ${i}: synthetic ${String(params.path)}`).join('\n'),
|
||||
}),
|
||||
onAiStreamData: (id: string, callback: (value: string) => void) => {
|
||||
data.set(id, callback);
|
||||
return () => { data.delete(id); };
|
||||
},
|
||||
onAiStreamEnd: (id: string, callback: () => void) => {
|
||||
end.set(id, callback);
|
||||
return () => { end.delete(id); };
|
||||
},
|
||||
onAiStreamError: () => () => undefined,
|
||||
aiChatCancel: async () => true,
|
||||
aiChatStream: async (id: string, url: string, _headers: Record<string, string>, body: string) => {
|
||||
urls.push(url);
|
||||
requests.push(JSON.parse(body) as WireRequest);
|
||||
const reply = replies[requests.length - 1];
|
||||
assert.ok(reply, 'unexpected model request');
|
||||
const send = (chunk: unknown) => data.get(id)?.(JSON.stringify(chunk));
|
||||
if (style === 'anthropic') {
|
||||
send({ type: 'message_start', message: {
|
||||
id: `response-${requests.length}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
|
||||
content: [], stop_reason: null, stop_sequence: null,
|
||||
usage: { input_tokens: 100, output_tokens: 0, cache_read_input_tokens: 900, cache_creation_input_tokens: 0 },
|
||||
} });
|
||||
send({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
||||
send({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'OK' } });
|
||||
send({ type: 'content_block_stop', index: 0 });
|
||||
send({ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
|
||||
send({ type: 'message_stop' });
|
||||
} else {
|
||||
const toolReply = 'tool' in reply;
|
||||
const delta = toolReply ? {
|
||||
role: 'assistant',
|
||||
tool_calls: [{ index: 0, id: `call-${requests.length}`, type: 'function', function: {
|
||||
name: reply.tool,
|
||||
arguments: JSON.stringify(reply.tool === 'terminal_execute'
|
||||
? { sessionId: 'terminal-1', command: `printf audit-${requests.length}` }
|
||||
: { sessionId: 'terminal-1', path: `/fixture/log-${requests.length}` }),
|
||||
} }],
|
||||
} : { role: 'assistant', content: reply.text };
|
||||
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model',
|
||||
choices: [{ index: 0, delta, finish_reason: null }] });
|
||||
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model',
|
||||
choices: [{ index: 0, delta: {}, finish_reason: toolReply ? 'tool_calls' : 'stop' }] });
|
||||
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model', choices: [],
|
||||
usage: { prompt_tokens: 1000, completion_tokens: 1, total_tokens: 1001,
|
||||
prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100 } });
|
||||
data.get(id)?.('[DONE]');
|
||||
}
|
||||
end.get(id)?.();
|
||||
return { ok: true, statusCode: 200 };
|
||||
},
|
||||
};
|
||||
globalThis.window = { netcatty: bridge } as unknown as Window & typeof globalThis;
|
||||
const store = new ToolOutputStore();
|
||||
const dedup = new ToolResultDedup();
|
||||
const state = new SessionStateStore();
|
||||
const provider: ProviderConfig = {
|
||||
id: 'fixture', providerId: 'custom', name: 'Fixture', enabled: true, style,
|
||||
defaultModel: style === 'anthropic' ? 'claude-sonnet-4-5' : 'test-model',
|
||||
baseURL: style === 'anthropic' ? 'https://api.anthropic.com' : 'https://provider.example.test/v1', apiKey: 'not-a-real-key',
|
||||
};
|
||||
let turnNumber = 0;
|
||||
return {
|
||||
requests, urls, events, messages, provider,
|
||||
async turn(maxIterations = 1) {
|
||||
turnNumber += 1;
|
||||
const history = structuredClone(messages);
|
||||
const userText = `Audit round ${turnNumber}`;
|
||||
const assistantId = `assistant-${turnNumber}`;
|
||||
messages.push({ id: `user-${turnNumber}`, role: 'user', content: userText, timestamp: turnNumber },
|
||||
{ id: assistantId, role: 'assistant', content: '', timestamp: turnNumber });
|
||||
const currentSession: AISession = {
|
||||
id: 'chat-1', title: 'Fixture', agentId: 'catty', scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
createdAt: 0, updatedAt: turnNumber, messages: history,
|
||||
};
|
||||
const signal = new AbortController().signal;
|
||||
const input: CattyTurnInput = {
|
||||
backend: 'catty', chatSessionId: 'chat-1', sendScopeKey: 'chat-1', userText, signal,
|
||||
currentSession, assistantMsgId: assistantId, maxIterations, bridge,
|
||||
context: {
|
||||
activeProvider: provider, activeModelId: provider.defaultModel!, scopeType: 'terminal', scopeLabel: 'Fixture',
|
||||
globalPermissionMode: 'auto', commandBlocklist: [], autoTitleSession: () => undefined,
|
||||
terminalSessions: [{ sessionId: 'terminal-1', hostname: 'fixture.example.test', label: 'Fixture', connected: true }],
|
||||
},
|
||||
ui: {
|
||||
addMessageToSession: (_session, message) => { messages.push(message); },
|
||||
updateMessageById: (_session, id, update) => {
|
||||
const index = messages.findIndex(message => message.id === id);
|
||||
if (index >= 0) messages[index] = update(messages[index]);
|
||||
},
|
||||
updateLastMessage: () => undefined,
|
||||
reportStreamError: (_session, _signal, error) => { throw error; },
|
||||
setStreamingForScope: () => undefined,
|
||||
},
|
||||
};
|
||||
const context: TurnDriverContext = {
|
||||
turnId: `turn-${turnNumber}`, chatSessionId: 'chat-1', sessionId: 'chat-1', backend: 'catty', signal,
|
||||
emit: event => { events.push({
|
||||
...event, sessionId: 'chat-1', chatSessionId: 'chat-1', backend: 'catty', timestamp: Date.now(),
|
||||
} as AgentEvent); }, toolOutputStore: store, toolResultDedup: dedup, sessionStateStore: state,
|
||||
};
|
||||
dedup.beginTurn();
|
||||
await new CattyTurnDriver().run(input, context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertRequestPrefix(previous: WireRequest, next: WireRequest) {
|
||||
assert.deepEqual(next.messages.slice(0, previous.messages.length), previous.messages,
|
||||
'already-sent conversation must remain an exact prefix');
|
||||
assert.deepEqual(next.tools, previous.tools);
|
||||
}
|
||||
|
||||
test('Catty preserves the request prefix across six ordinary turns', async t => {
|
||||
const f = fixture(t, Array.from({ length: 6 }, () => ({ text: 'OK' })));
|
||||
for (let i = 0; i < 6; i += 1) await f.turn();
|
||||
assert.equal(f.requests.length, 6);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
});
|
||||
|
||||
test('Catty replays completed small commands without rewriting their results', async t => {
|
||||
const f = fixture(t, Array.from({ length: 6 }, () => [
|
||||
{ tool: 'terminal_execute' as const }, { text: 'OK' },
|
||||
]).flat());
|
||||
for (let i = 0; i < 6; i += 1) await f.turn(2);
|
||||
assert.equal(f.requests.length, 12);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 1800, 'usage includes both model steps');
|
||||
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/, 'OpenAI-compatible requests use implicit caching');
|
||||
});
|
||||
|
||||
test('Catty keeps large-output handles without replacing the conversation prefix', async t => {
|
||||
const f = fixture(t, [{ tool: 'sftp_read_file' }, { tool: 'sftp_read_file' }, { text: 'OK' }]);
|
||||
await f.turn(3);
|
||||
assert.equal(f.requests.length, 3);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
assert.match(JSON.stringify(f.requests.at(-1)), /tool output handle:/);
|
||||
assert.doesNotMatch(JSON.stringify(f.requests.at(-1)), /Tool output handles available/);
|
||||
});
|
||||
|
||||
test('Catty preserves large-output history across consecutive user turns', async t => {
|
||||
const f = fixture(t, Array.from({ length: 6 }, () => [
|
||||
{ tool: 'sftp_read_file' as const }, { text: 'OK' },
|
||||
]).flat());
|
||||
for (let i = 0; i < 6; i += 1) await f.turn(2);
|
||||
assert.equal(f.requests.length, 12);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
});
|
||||
|
||||
test('Catty preserves large terminal results across consecutive user turns', async t => {
|
||||
const f = fixture(t, Array.from({ length: 6 }, () => [
|
||||
{ tool: 'terminal_execute' as const }, { text: 'OK' },
|
||||
]).flat(), 'openai', Array.from({ length: 14000 }, (_, i) => `synthetic log record ${i}`).join('\n'));
|
||||
for (let i = 0; i < 6; i += 1) await f.turn(2);
|
||||
assert.equal(f.requests.length, 12);
|
||||
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
|
||||
});
|
||||
|
||||
test('Catty keeps DeepSeek cache reads in trace and persisted assistant usage', async t => {
|
||||
const f = fixture(t, [{ text: 'OK' }]);
|
||||
await f.turn();
|
||||
const event = f.events.find(event => event.type === 'usage');
|
||||
assert.ok(event?.type === 'usage');
|
||||
assert.equal(event.cachedPromptTokens, 900);
|
||||
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 900);
|
||||
});
|
||||
|
||||
test('Catty marks a stable Claude system block and the conversation tail for caching', async t => {
|
||||
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
|
||||
await f.turn();
|
||||
const system = f.requests[0].system!;
|
||||
assert.ok(system.length >= 2, 'static and dynamic system context need separate boundaries');
|
||||
assert.deepEqual(system[0].cache_control, { type: 'ephemeral' });
|
||||
assert.doesNotMatch(system[0].text, /## Current Scope/);
|
||||
assert.match(system[1].text, /## Current Scope/);
|
||||
assert.match(JSON.stringify(f.requests[0].messages.at(-1)), /cache_control/);
|
||||
const event = f.events.find(event => event.type === 'usage');
|
||||
assert.ok(event?.type === 'usage');
|
||||
assert.equal(event.cachedPromptTokens, 900);
|
||||
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 900);
|
||||
});
|
||||
|
||||
test('Catty does not send vendor cache extensions to an Anthropic-compatible gateway', async t => {
|
||||
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
|
||||
f.provider.baseURL = 'https://gateway.example.test/anthropic';
|
||||
await f.turn();
|
||||
assert.equal(f.requests.length, 1);
|
||||
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/);
|
||||
assert.equal(f.messages.at(-1)?.content, 'OK');
|
||||
});
|
||||
|
||||
for (const providerId of ['openrouter', 'ollama'] as const) {
|
||||
test(`Catty respects the ${providerId} endpoint fallback with Anthropic format`, async t => {
|
||||
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
|
||||
f.provider.providerId = providerId;
|
||||
f.provider.baseURL = '';
|
||||
await f.turn();
|
||||
assert.match(f.urls[0], providerId === 'openrouter' ? /^https:\/\/openrouter\.ai\// : /^http:\/\/localhost:11434\//);
|
||||
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/);
|
||||
assert.equal(f.messages.at(-1)?.content, 'OK');
|
||||
});
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { ModelMessage, SystemModelMessage } from 'ai';
|
||||
import type { ProviderConfig } from '../types';
|
||||
|
||||
const CACHE_CONTROL = { type: 'ephemeral' } as const;
|
||||
|
||||
/** Native Anthropic needs explicit breakpoints; implicit-cache protocols stay untouched. */
|
||||
export function createCattyPromptCache(
|
||||
systemPrompt: string,
|
||||
provider: string,
|
||||
endpoint?: Pick<ProviderConfig, 'baseURL'>,
|
||||
) {
|
||||
let officialEndpoint = false;
|
||||
if (endpoint) {
|
||||
try {
|
||||
// This must be the resolved SDK address, including vendor fallbacks.
|
||||
// Only an unresolved URL after that step uses the SDK's native default.
|
||||
officialEndpoint = new URL(endpoint.baseURL || 'https://api.anthropic.com').origin === 'https://api.anthropic.com';
|
||||
} catch { /* An unknown endpoint must not receive vendor extensions. */ }
|
||||
}
|
||||
const enabled = /^anthropic(?:\.|$)/.test(provider) && officialEndpoint;
|
||||
const boundary = systemPrompt.indexOf('\n\n## Current Scope');
|
||||
const instructions: string | SystemModelMessage[] = enabled
|
||||
? [{
|
||||
role: 'system',
|
||||
content: boundary < 0 ? systemPrompt : systemPrompt.slice(0, boundary),
|
||||
providerOptions: { anthropic: { cacheControl: CACHE_CONTROL } },
|
||||
}, ...(boundary < 0 ? [] : [{
|
||||
role: 'system' as const,
|
||||
content: systemPrompt.slice(boundary),
|
||||
}])]
|
||||
: systemPrompt;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
instructions,
|
||||
prepareMessages(messages: ModelMessage[]): ModelMessage[] {
|
||||
if (!enabled) return messages;
|
||||
// Keep at most two moving conversation breakpoints, plus the static
|
||||
// system boundary. Remove our prior message-level markers as v7 carries
|
||||
// prepared messages forward; never alter signed reasoning content.
|
||||
const targets = new Set<number>();
|
||||
for (let index = messages.length - 1; index >= 0 && targets.size < 2; index -= 1) {
|
||||
if (messages[index].role === 'user' || messages[index].role === 'tool') targets.add(index);
|
||||
}
|
||||
return messages.map((message, index) => {
|
||||
const existing = message.providerOptions?.anthropic;
|
||||
if (!targets.has(index) && !existing?.cacheControl && !existing?.cache_control) return message;
|
||||
const anthropic = { ...existing };
|
||||
delete anthropic.cacheControl;
|
||||
delete anthropic.cache_control;
|
||||
if (targets.has(index)) anthropic.cacheControl = CACHE_CONTROL;
|
||||
return { ...message, providerOptions: { ...message.providerOptions, anthropic } };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { createCattyPromptCache } from './cattyPromptCache';
|
||||
|
||||
test('leaves implicit-cache protocols unchanged', () => {
|
||||
for (const provider of ['openai.chat', 'openai.responses', 'google.generative-ai']) {
|
||||
const cache = createCattyPromptCache('instructions', provider);
|
||||
const messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
assert.equal(cache.instructions, 'instructions');
|
||||
assert.equal(cache.prepareMessages(messages), messages);
|
||||
}
|
||||
});
|
||||
|
||||
test('Claude markers stay bounded across tool steps without changing signed reasoning', () => {
|
||||
const cache = createCattyPromptCache('static\n\n## Current Scope\nvolatile', 'anthropic.messages', { baseURL: 'https://api.anthropic.com' });
|
||||
const reasoning = { type: 'reasoning' as const, text: 'thinking',
|
||||
providerOptions: { anthropic: { signature: 'signed-value' } } };
|
||||
let messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
for (let step = 0; step < 30; step += 1) {
|
||||
messages = cache.prepareMessages(messages);
|
||||
const marked = messages.filter(message => message.providerOptions?.anthropic?.cacheControl);
|
||||
assert.ok(marked.length <= 2);
|
||||
messages.push({ role: 'assistant', content: [reasoning, { type: 'text', text: 'result' }] });
|
||||
messages.push({ role: 'user', content: `next-${step}` });
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (message.role === 'assistant') assert.equal(message.content[0], reasoning);
|
||||
}
|
||||
assert.ok(Array.isArray(cache.instructions));
|
||||
assert.deepEqual(cache.instructions[0].providerOptions?.anthropic?.cacheControl, { type: 'ephemeral' });
|
||||
});
|
||||
|
||||
test('only the official HTTPS Anthropic endpoint enables vendor cache markers', () => {
|
||||
for (const baseURL of ['https://proxy.example.test/v1', 'http://api.anthropic.com', 'https://api.anthropic.com.example.test', 'invalid']) {
|
||||
const cache = createCattyPromptCache('instructions', 'anthropic.messages', { baseURL });
|
||||
assert.equal(cache.enabled, false);
|
||||
}
|
||||
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages').enabled, false);
|
||||
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages', {}).enabled, true);
|
||||
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages', { baseURL: 'https://api.anthropic.com/v1' }).enabled, true);
|
||||
});
|
||||
@@ -78,7 +78,7 @@ test('prepareTurnContext applies typed compression before LLM summarize threshol
|
||||
assert.ok(prepared.messages.length >= messages.length);
|
||||
const serialized = JSON.stringify(prepared.messages);
|
||||
assert.match(serialized, /Fix only the upstream timeout issue/);
|
||||
assert.doesNotMatch(serialized, /Netcatty session context/);
|
||||
assert.match(serialized, /Permission mode: confirm/);
|
||||
assert.ok(serialized.length < JSON.stringify(messages).length);
|
||||
});
|
||||
|
||||
@@ -163,7 +163,7 @@ test('TraceStore bounds compaction history independently from events', () => {
|
||||
assert.equal(store.getCompactions('chat-bounded-compactions').length, 3);
|
||||
});
|
||||
|
||||
test('prepareStepContext preserves prior notices and appends only newly available handles', async () => {
|
||||
test('prepareStepContext replaces prior step handle notices under v7 carry-forward semantics', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
store.store({
|
||||
chatSessionId: 'chat-4',
|
||||
@@ -194,22 +194,9 @@ test('prepareStepContext preserves prior notices and appends only newly availabl
|
||||
&& typeof message.content === 'string'
|
||||
&& message.content.includes('Tool output handles available'),
|
||||
);
|
||||
assert.equal(notices.length, 2);
|
||||
assert.deepEqual(prepared.messages.slice(0, 2), [priorNotice, { role: 'user', content: 'continue' }]);
|
||||
assert.equal(notices[0], priorNotice);
|
||||
assert.match(String(notices[1]?.content), /^\[Tool output handles available:/);
|
||||
assert.doesNotMatch(String(notices[1]?.content), /tool-output-old|\[step/);
|
||||
assert.equal(prepared.messages.at(-1), notices[1]);
|
||||
|
||||
const next = await prepareStepContext({
|
||||
messages: prepared.messages,
|
||||
stepNumber: 3,
|
||||
sessionId: 'chat-4',
|
||||
chatSessionId: 'chat-4',
|
||||
toolOutputStore: store,
|
||||
runtimeContext,
|
||||
});
|
||||
assert.deepEqual(next.messages, prepared.messages, 'unchanged handles need no per-step rewrite');
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(String(notices[0]?.content), /\[step 2\]/);
|
||||
assert.doesNotMatch(String(notices[0]?.content), /tool-output-old/);
|
||||
});
|
||||
|
||||
test('prepareStepContext emits step compaction trace when over budget', async () => {
|
||||
@@ -279,8 +266,7 @@ test('prepareStepContext retains handle notice after step budget guard', async (
|
||||
&& message.content.includes('Tool output handles available'),
|
||||
);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(String(notices[0]?.content), /^\[Tool output handles available:/);
|
||||
assert.equal(prepared.messages.at(-1), notices[0]);
|
||||
assert.match(String(notices[0]?.content), /\[step 2\]/);
|
||||
});
|
||||
|
||||
test('prepareStepContext never leaves orphan results from a parallel tool batch', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type { ChatMessage, AIPermissionMode } from '../types';
|
||||
import { isStepHandleNoticeMessage } from './agentEventAdapter';
|
||||
import {
|
||||
DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
@@ -264,9 +265,7 @@ export async function prepareTurnContext(
|
||||
}
|
||||
|
||||
const reinjection = buildReinjectionMessages(input.reinjection);
|
||||
// Routine output cleanup does not discard the conversation or its goals.
|
||||
// Reinserting the latest goal at the front would invalidate every turn.
|
||||
if (reinjection.length > 0 && didAdjust && (underBudgetPressure || didLlmSummarize)) {
|
||||
if (reinjection.length > 0 && didAdjust) {
|
||||
const reinjectionTokens = estimateModelMessagesTokensWithKind({
|
||||
messages: reinjection,
|
||||
providerId: input.providerId,
|
||||
@@ -415,6 +414,22 @@ export async function prepareStepContext(
|
||||
const typed = compressMessagesForRequestTooLargeRetry(working);
|
||||
working = typed.messages;
|
||||
|
||||
const pendingHandles = input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? [];
|
||||
let didHandleNotice = false;
|
||||
if (pendingHandles.length > 0 && input.stepNumber > 0) {
|
||||
working = working.filter((message) => {
|
||||
if (message.role !== 'user') return true;
|
||||
const content = typeof message.content === 'string' ? message.content : '';
|
||||
return !isStepHandleNoticeMessage(content);
|
||||
});
|
||||
const notice: ModelMessage = {
|
||||
role: 'user',
|
||||
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
|
||||
};
|
||||
working = [notice, ...working];
|
||||
didHandleNotice = true;
|
||||
}
|
||||
|
||||
const budgetGuard = applyStepBudgetGuard(working, {
|
||||
contextWindow,
|
||||
reservedTokens: input.reservedTokens ?? 0,
|
||||
@@ -423,42 +438,36 @@ export async function prepareStepContext(
|
||||
protectRecentMessages: protectRecent,
|
||||
});
|
||||
working = budgetGuard.messages;
|
||||
const pendingHandles = input.stepNumber > 0
|
||||
? input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? []
|
||||
: [];
|
||||
const unannouncedHandles = () => {
|
||||
// Bounded tool results already carry their read handles. Announce only
|
||||
// handles whose result was pruned, so normal history needs no transient
|
||||
// user message that would disappear when the next turn is rebuilt.
|
||||
const visibleContent = working.map(message => typeof message.content === 'string'
|
||||
? message.content : JSON.stringify(message.content));
|
||||
return pendingHandles.filter(handle => !visibleContent.some(content => content.includes(handle.id)));
|
||||
};
|
||||
let didHandleNotice = false;
|
||||
if (unannouncedHandles().length > 0) {
|
||||
// Reserve the complete list: the guard may remove an older announcement.
|
||||
// New notices belong at the tail; replacing a step-numbered prefix would
|
||||
// invalidate the entire cached conversation on every model call.
|
||||
if (didHandleNotice && pendingHandles.length > 0) {
|
||||
const hasHandleNotice = working.some(
|
||||
(message) => message.role === 'user' && isStepHandleNoticeMessage(message.content),
|
||||
);
|
||||
if (!hasHandleNotice) {
|
||||
working = [{
|
||||
role: 'user',
|
||||
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
|
||||
}, ...working];
|
||||
}
|
||||
}
|
||||
if (didHandleNotice) {
|
||||
const notices = working.filter(
|
||||
message => message.role === 'user' && isStepHandleNoticeMessage(message.content),
|
||||
);
|
||||
const body = working.filter(
|
||||
message => !(message.role === 'user' && isStepHandleNoticeMessage(message.content)),
|
||||
);
|
||||
const noticeTokens = estimateModelMessagesTokensWithKind({
|
||||
messages: [{ role: 'user', content: `[Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}]` }],
|
||||
messages: notices,
|
||||
providerId: input.providerId,
|
||||
}).tokens;
|
||||
const finalGuard = applyStepBudgetGuard(working, {
|
||||
const finalGuard = applyStepBudgetGuard(body, {
|
||||
contextWindow,
|
||||
reservedTokens: (input.reservedTokens ?? 0) + noticeTokens,
|
||||
maxOutputTokens,
|
||||
providerId: input.providerId,
|
||||
protectRecentMessages: protectRecent,
|
||||
});
|
||||
working = finalGuard.messages;
|
||||
const handles = unannouncedHandles();
|
||||
if (handles.length > 0) {
|
||||
working = [...working, {
|
||||
role: 'user',
|
||||
content: `[Tool output handles available: ${handles.map(h => h.id).join(', ')}]`,
|
||||
}];
|
||||
didHandleNotice = true;
|
||||
}
|
||||
working = [...notices, ...finalGuard.messages];
|
||||
budgetGuard.didAdjust = budgetGuard.didAdjust || finalGuard.didAdjust;
|
||||
}
|
||||
const didBudgetAdjust = integrity.didAdjust || stale.didAdjust || typed.didAdjust || budgetGuard.didAdjust;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
type AssistantContentPart,
|
||||
type CattyProviderContinuationContext,
|
||||
} from '../../aiChatStreamingSupport';
|
||||
import { redactSecretsForModel, redactSecretsInValueForModel } from '../modelSecretRedaction';
|
||||
import { redactSecretsInValueForModel } from '../modelSecretRedaction';
|
||||
import { fitLargeUserInputForModel } from '../largeUserInput';
|
||||
import type { ToolOutputStore } from '../toolOutputStore';
|
||||
|
||||
@@ -198,6 +198,7 @@ export interface BuildCattySdkMessagesInput {
|
||||
trimmed: string;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
continuationContext: CattyProviderContinuationContext;
|
||||
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
|
||||
chatSessionId: string;
|
||||
toolOutputStore: ToolOutputStore;
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
|
||||
@@ -211,6 +212,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
trimmed,
|
||||
attachments,
|
||||
continuationContext,
|
||||
preserveTerminalToolResults = new Set<ToolResult>(),
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
fieldsByMessage,
|
||||
@@ -392,12 +394,9 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
toolName: toolCall?.name ?? 'unknown',
|
||||
output: {
|
||||
type: 'text' as const,
|
||||
// Live results are already bounded. Reformatting them merely
|
||||
// because another user turn arrived breaks the cached prefix.
|
||||
// Context-budget pruning still owns necessary reductions.
|
||||
value: redactSecretsForModel(buildHistoricalToolResultReplayText(tr, toolCall, {
|
||||
preserveTerminalOutput: true,
|
||||
})),
|
||||
value: buildHistoricalToolResultReplayText(tr, toolCall, {
|
||||
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -8,14 +8,13 @@ import {
|
||||
} from '../../cattyRequestTooLargeRetry';
|
||||
import { mapCattyStreamChunkToAgentEvents } from '../agentEventAdapter';
|
||||
import type { AgentEvent } from '../types';
|
||||
import { resolveProviderStyle, type ProviderAdvancedParams } from '../../types';
|
||||
import type { ProviderAdvancedParams } from '../../types';
|
||||
import type { CattyReasoningProviderOptions } from '../../cattyReasoning';
|
||||
import { createModelFromConfig, resolveProviderEndpoint } from '../../sdk/providers';
|
||||
import { createModelFromConfig } from '../../sdk/providers';
|
||||
import type { CattyToolsBundle } from '../capabilityTools';
|
||||
import { buildCattyToolApproval } from '../cattyToolApproval';
|
||||
import type { CattyRuntimeContext } from '../cattyRuntimeContext';
|
||||
import { buildCattyStreamTimeouts } from '../streamTimeouts';
|
||||
import { createCattyPromptCache } from '../cattyPromptCache';
|
||||
import {
|
||||
extractProviderContinuationFromRawChunk,
|
||||
mergeProviderContinuation,
|
||||
@@ -40,7 +39,7 @@ import {
|
||||
type ToolOutputDeniedChunk,
|
||||
type ToolResultChunk,
|
||||
} from '../../aiChatStreamingSupport';
|
||||
import type { ChatMessage, ProviderConfig } from '../../types';
|
||||
import type { ChatMessage } from '../../types';
|
||||
|
||||
export type CattyModel = ReturnType<typeof createModelFromConfig>;
|
||||
|
||||
@@ -52,7 +51,6 @@ export interface CattyStreamUiSink {
|
||||
export interface ProcessCattyStreamInput {
|
||||
streamSessionId: string;
|
||||
model: CattyModel;
|
||||
providerConfig?: ProviderConfig;
|
||||
systemPrompt: string;
|
||||
toolsBundle: CattyToolsBundle;
|
||||
sdkMessages: ModelMessage[];
|
||||
@@ -78,7 +76,6 @@ export interface ProcessCattyStreamInput {
|
||||
export interface ProcessCattyStreamResult {
|
||||
usage?: {
|
||||
promptTokens?: number;
|
||||
cachedPromptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
@@ -132,15 +129,11 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
|
||||
let runtimeContext = initialRuntimeContext;
|
||||
const { tools, toolsContext } = toolsBundle;
|
||||
const endpoint = input.providerConfig
|
||||
? resolveProviderEndpoint(input.providerConfig, resolveProviderStyle(input.providerConfig), undefined)
|
||||
: undefined;
|
||||
const promptCache = createCattyPromptCache(systemPrompt, model.provider, endpoint);
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: promptCache.prepareMessages(sdkMessages),
|
||||
instructions: promptCache.instructions,
|
||||
messages: sdkMessages,
|
||||
instructions: systemPrompt,
|
||||
tools,
|
||||
toolsContext,
|
||||
runtimeContext,
|
||||
@@ -193,7 +186,6 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
modelId: step.model.modelId,
|
||||
finishReason: step.finishReason,
|
||||
promptTokens: usage.inputTokens ?? 0,
|
||||
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
completionTokens: usage.outputTokens ?? 0,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
} as AgentEvent);
|
||||
@@ -209,17 +201,15 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
timestamp: Date.now(),
|
||||
turnId,
|
||||
promptTokens: usage.inputTokens ?? 0,
|
||||
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
completionTokens: usage.outputTokens ?? 0,
|
||||
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
estimated: false,
|
||||
} as AgentEvent);
|
||||
}
|
||||
},
|
||||
...(prepareStep || promptCache.enabled ? {
|
||||
...(prepareStep ? {
|
||||
prepareStep: async ({ stepNumber, messages, runtimeContext: stepRuntimeContext }) => {
|
||||
const prepared = await prepareStep?.({
|
||||
const prepared = await prepareStep({
|
||||
stepNumber,
|
||||
messages,
|
||||
runtimeContext: stepRuntimeContext as CattyRuntimeContext,
|
||||
@@ -227,7 +217,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
if (prepared?.runtimeContext) {
|
||||
runtimeContext = prepared.runtimeContext;
|
||||
}
|
||||
return { ...prepared, messages: promptCache.prepareMessages(prepared?.messages ?? messages) };
|
||||
return prepared ?? { messages };
|
||||
},
|
||||
} : {}),
|
||||
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
|
||||
@@ -562,21 +552,6 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
const usage = await result.usage;
|
||||
const finalStep = await result.finalStep;
|
||||
const performance = finalStep?.performance;
|
||||
// onEnd can run before this consumer flushes its final text and creates the
|
||||
// post-tool assistant message. Attach turn totals only after that flush.
|
||||
if (usage) {
|
||||
ui.updateMessageById(streamSessionId, activeMsgId, message => ({
|
||||
...message,
|
||||
usage: {
|
||||
inputTokens: usage.inputTokens ?? 0,
|
||||
cachedInputTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
outputTokens: usage.outputTokens ?? 0,
|
||||
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
estimated: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (performance) {
|
||||
onAgentEvent?.({
|
||||
@@ -596,7 +571,6 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
return {
|
||||
usage: usage ? {
|
||||
promptTokens: usage.inputTokens,
|
||||
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
completionTokens: usage.outputTokens,
|
||||
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
} : undefined,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
buildCattySdkMessages,
|
||||
collectOpenAIChatAssistantFieldsForMessages,
|
||||
collectPreservedTerminalWriteFingerprints,
|
||||
collectToolResultsAfterMessage,
|
||||
createContinuationContext,
|
||||
} from './cattyMessageBuilder';
|
||||
import { hadToolProgressBeforeRequestTooLarge, processCattyStream } from './cattyStreamProcessor';
|
||||
@@ -217,6 +218,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
const buildSdkMessages = (
|
||||
allMessages: import('../../types').ChatMessage[],
|
||||
includeCurrentUserMessage: boolean,
|
||||
options: { preserveTerminalToolResults?: ReadonlySet<import('../../types').ToolResult> } = {},
|
||||
sessionContextCompaction = currentSession?.contextCompaction,
|
||||
) => buildCattySdkMessages({
|
||||
allMessages,
|
||||
@@ -225,6 +227,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
trimmed: modelUserText,
|
||||
attachments: includeCurrentUserMessage ? attachments : undefined,
|
||||
continuationContext,
|
||||
preserveTerminalToolResults: options.preserveTerminalToolResults,
|
||||
chatSessionId: sessionId,
|
||||
toolOutputStore: ctx.toolOutputStore,
|
||||
fieldsByMessage: openAIChatAssistantFieldsByMessage,
|
||||
@@ -396,6 +399,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
const headSdkMessages = buildSdkMessages(
|
||||
uiMessages.slice(0, compactedMessageCount),
|
||||
false,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
const compacted = await compactMessages(headSdkMessages, {
|
||||
@@ -410,7 +414,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
};
|
||||
ui.persistContextCompaction?.(sessionId, nextCompaction);
|
||||
emitContextSnapshot(prepareMessagesForStream(
|
||||
buildSdkMessages(uiMessages, false, nextCompaction),
|
||||
buildSdkMessages(uiMessages, false, {}, nextCompaction),
|
||||
));
|
||||
} else {
|
||||
// Non-durable outcome: keep the meter honest (full current context).
|
||||
@@ -448,7 +452,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
await processCattyStream({
|
||||
streamSessionId: sessionId,
|
||||
model,
|
||||
providerConfig: context.activeProvider,
|
||||
systemPrompt,
|
||||
toolsBundle,
|
||||
sdkMessages: streamMessages,
|
||||
@@ -511,7 +514,12 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
assistantMsgId,
|
||||
sessionId,
|
||||
);
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
|
||||
preserveTerminalToolResults: collectToolResultsAfterMessage(
|
||||
latestSession.messages,
|
||||
assistantMsgId,
|
||||
),
|
||||
}, latestSession.contextCompaction);
|
||||
}
|
||||
retryAssistantMsgId = generateId();
|
||||
ui.addMessageToSession(sessionId, {
|
||||
|
||||
@@ -180,7 +180,6 @@ export interface StepEndEvent extends AgentEventBase {
|
||||
modelId?: string;
|
||||
finishReason?: string;
|
||||
promptTokens: number;
|
||||
cachedPromptTokens?: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeOpenAIChatCacheUsage } from './openAIChatCacheUsage';
|
||||
|
||||
test('adapts documented DeepSeek cache usage while preserving the rest of the response', () => {
|
||||
const response = { choices: [], usage: {
|
||||
prompt_tokens: 1000, prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100,
|
||||
prompt_tokens_details: { other: 1 }, completion_tokens: 5,
|
||||
} };
|
||||
const parsed = JSON.parse(normalizeOpenAIChatCacheUsage(JSON.stringify(response)));
|
||||
assert.deepEqual(parsed, {
|
||||
...response, usage: { ...response.usage, prompt_tokens_details: { other: 1, cached_tokens: 900 } },
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves standard usage and rejects invalid cache counts without breaking stream data', () => {
|
||||
for (const hit of [-1, 1.5, '900', 1001, null]) {
|
||||
const data = JSON.stringify({ usage: { prompt_tokens: 1000, prompt_cache_hit_tokens: hit } });
|
||||
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
|
||||
}
|
||||
for (const data of ['[DONE]', 'not JSON', '{"usage":null}', '{"type":"message_start"}']) {
|
||||
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
|
||||
}
|
||||
const data = JSON.stringify({ usage: {
|
||||
prompt_tokens: 1000, prompt_cache_hit_tokens: 900, prompt_tokens_details: { cached_tokens: 0 },
|
||||
} });
|
||||
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/** Adapt DeepSeek's documented usage fields before the OpenAI SDK validates them. */
|
||||
export function normalizeOpenAIChatCacheUsage(data: string): string {
|
||||
if (!data.includes('prompt_cache_hit_tokens')) return data;
|
||||
try {
|
||||
const body = JSON.parse(data);
|
||||
const usage = body?.usage;
|
||||
const hit = usage?.prompt_cache_hit_tokens;
|
||||
if (!Number.isSafeInteger(hit) || hit < 0) return data;
|
||||
if (Number.isFinite(usage.prompt_tokens) && hit > usage.prompt_tokens) return data;
|
||||
// Preserve a provider's standard field, including an explicit zero.
|
||||
if (usage.prompt_tokens_details?.cached_tokens != null) return data;
|
||||
usage.prompt_tokens_details = { ...usage.prompt_tokens_details, cached_tokens: hit };
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { generateText, isStepCount, streamText, tool } from 'ai';
|
||||
import { isStepCount, streamText, tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { createBridgeFetchForSDK, createModelFromConfig } from './sdk/providers';
|
||||
import type { OpenAIChatAssistantFields } from './providerContinuation';
|
||||
@@ -1305,25 +1305,3 @@ for (const indexes of [[0], [1], [1, 4], [4, 1]]) {
|
||||
indexes.map(index => `call_${index}`).sort());
|
||||
});
|
||||
}
|
||||
|
||||
test('non-streaming DeepSeek usage reaches the SDK as cached input', async t => {
|
||||
const originalWindow = globalThis.window;
|
||||
t.after(() => { globalThis.window = originalWindow; });
|
||||
globalThis.window = { netcatty: {
|
||||
aiFetch: async () => ({ ok: true, status: 200, data: JSON.stringify({
|
||||
id: 'response', object: 'chat.completion', created: 0, model: 'deepseek-chat',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1000, completion_tokens: 1, total_tokens: 1001,
|
||||
prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100 },
|
||||
}) }),
|
||||
} } as unknown as Window & typeof globalThis;
|
||||
const result = await generateText({
|
||||
model: createModelFromConfig({
|
||||
id: 'fixture', providerId: 'custom', name: 'Fixture', enabled: true,
|
||||
baseURL: 'https://provider.example.test/v1', apiKey: 'fixture', defaultModel: 'deepseek-chat',
|
||||
}),
|
||||
prompt: 'hello',
|
||||
});
|
||||
assert.equal(result.usage.inputTokenDetails.cacheReadTokens, 900);
|
||||
assert.equal(result.usage.inputTokenDetails.noCacheTokens, 100);
|
||||
});
|
||||
@@ -158,19 +158,13 @@ function compressContentPartForRequestRetry(part: unknown): unknown {
|
||||
const output = record.output;
|
||||
if (output && typeof output === "object") {
|
||||
const outputRecord = output as Record<string, unknown>;
|
||||
const text = outputRecord.type === "text" && typeof outputRecord.value === "string"
|
||||
? outputRecord.value
|
||||
: outputRecord.type === "json" ? JSON.stringify(outputRecord.value) : undefined;
|
||||
if (text !== undefined) {
|
||||
// Live SDK tool results are JSON, while persisted results replay as
|
||||
// text. Apply the same reduction before their first model request.
|
||||
const value = compressAndTruncateText(text, RETRY_MAX_TOOL_RESULT_CHARS);
|
||||
if (value === text) return part;
|
||||
if (outputRecord.type === "text" && typeof outputRecord.value === "string") {
|
||||
const value = compressAndTruncateText(outputRecord.value, RETRY_MAX_TOOL_RESULT_CHARS);
|
||||
if (value === outputRecord.value) return part;
|
||||
return {
|
||||
...record,
|
||||
output: {
|
||||
...outputRecord,
|
||||
type: "text",
|
||||
value,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { resolveOpenAIApi, resolveProviderStyle } from '../types';
|
||||
import { normalizeAnthropicSdkBaseURL } from '../anthropicCompatBaseUrl';
|
||||
import { buildSdkRequestHeaders } from '../providerRequestHeaders';
|
||||
import { normalizeOllamaSdkBaseURL } from '../ollamaCompatBaseUrl';
|
||||
import { normalizeOpenAIChatCacheUsage } from '../openAIChatCacheUsage';
|
||||
|
||||
export { normalizeOllamaSdkBaseURL };
|
||||
import {
|
||||
@@ -530,7 +529,7 @@ export function createBridgeFetchForSDK(
|
||||
};
|
||||
|
||||
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
|
||||
const normalizedData = normalizeOpenAIChatCacheUsage(normalizeOpenAIChatToolCalls(data));
|
||||
const normalizedData = normalizeOpenAIChatToolCalls(data);
|
||||
captureOpenAIChatFields(normalizedData);
|
||||
// Re-wrap as SSE so the SDK can parse it
|
||||
enqueueChunk(encoder.encode(`data: ${normalizedData}\n\n`));
|
||||
@@ -628,7 +627,7 @@ export function createBridgeFetchForSDK(
|
||||
// Non-streaming path
|
||||
const result = await bridge.aiFetch(url, method, headers, requestBody, providerId);
|
||||
|
||||
return new Response(normalizeOpenAIChatCacheUsage(result.data), {
|
||||
return new Response(result.data, {
|
||||
status: result.status,
|
||||
statusText: result.ok ? 'OK' : 'Error',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
|
||||
@@ -321,6 +321,3 @@ export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';
|
||||
|
||||
// Portable monitor configuration. Runtime samples never use storage.
|
||||
export const STORAGE_KEY_MONITOR_PANELS = 'netcatty_monitor_panels_v1';
|
||||
export const STORAGE_KEY_MONITOR_TABLE_COLUMNS = 'netcatty_monitor_table_columns_v1';
|
||||
|
||||
export const STORAGE_KEY_MONITOR_HIDDEN_COLUMNS = "netcatty_monitor_hidden_columns_v1";
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { BrowserBookmark } from '../../domain/browserBookmark';
|
||||
import type { HostBrowserCommand, HostBrowserPanelEvent } from '../../domain/hostBrowser';
|
||||
import { HostBrowserIconError } from '../../domain/hostBrowser';
|
||||
import { netcattyBridge } from './netcattyBridge';
|
||||
|
||||
export function hostBrowserClient(browserId: string) {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.getHostBrowserPanelState || !bridge.commandHostBrowserPanel) {
|
||||
throw new Error('Restart Netcatty to load the updated browser controls');
|
||||
}
|
||||
return {
|
||||
state: () => bridge.getHostBrowserPanelState!(browserId),
|
||||
command: (command: HostBrowserCommand) => bridge.commandHostBrowserPanel!(browserId, command),
|
||||
bookmarks: () => bridge.listHostBrowserPanelBookmarks!(browserId),
|
||||
saveBookmark: (bookmark: Partial<BrowserBookmark>) => bridge.saveHostBrowserPanelBookmark!(browserId, bookmark),
|
||||
removeBookmark: (id: string) => bridge.removeHostBrowserPanelBookmark!(browserId, id),
|
||||
downloads: () => bridge.listHostBrowserPanelDownloads!(browserId),
|
||||
downloadCommand: (command: Parameters<NonNullable<NetcattyBridge['commandHostBrowserPanelDownload']>>[1]) =>
|
||||
bridge.commandHostBrowserPanelDownload!(browserId, command),
|
||||
subscribe: (callback: (event: HostBrowserPanelEvent) => void) => bridge.onHostBrowserPanelEvent?.((event) => {
|
||||
if (event.browserId === browserId) callback(event);
|
||||
}) ?? (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readHostBrowserIcon(file: File): Promise<string> {
|
||||
if (!new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon']).has(file.type)
|
||||
) throw new HostBrowserIconError('file-type');
|
||||
if (file.size > 5 * 1024 * 1024) throw new HostBrowserIconError('file-size');
|
||||
const source = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new HostBrowserIconError('decode'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const element = new Image();
|
||||
element.onload = () => resolve(element);
|
||||
element.onerror = () => reject(new HostBrowserIconError('decode'));
|
||||
element.src = source;
|
||||
});
|
||||
if (!image.naturalWidth || !image.naturalHeight) throw new HostBrowserIconError('decode');
|
||||
if (file.size <= 64 * 1024 && Math.max(image.naturalWidth, image.naturalHeight) <= 128) return source;
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = Math.min(1, 96 / Math.max(image.naturalWidth, image.naturalHeight));
|
||||
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new HostBrowserIconError('decode');
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./monitor-panels.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,218 +0,0 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider";
|
||||
import { writeMonitorPanels } from "../../application/state/monitorPanelsStorage";
|
||||
import {
|
||||
monitorActions,
|
||||
publishMonitorSnapshot,
|
||||
} from "../../application/state/monitorRuntimeStore";
|
||||
import { MonitorPanels } from "../../components/vault/MonitorPanels";
|
||||
import type { Host } from "../../domain/models";
|
||||
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
|
||||
import "../../index.css";
|
||||
|
||||
const tokens = {
|
||||
background: "210 25% 18%",
|
||||
foreground: "210 20% 90%",
|
||||
card: "210 23% 21%",
|
||||
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%",
|
||||
"popover-foreground": "210 20% 90%",
|
||||
destructive: "0 70% 60%",
|
||||
input: "210 20% 30%",
|
||||
ring: "170 70% 42%",
|
||||
};
|
||||
for (const [key, value] of Object.entries(tokens))
|
||||
document.documentElement.style.setProperty(`--${key}`, value);
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.style.setProperty("--radius", "8px");
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
let connections: string[] = [];
|
||||
let retries: {
|
||||
id: string;
|
||||
credentials?: { username: string; password: string };
|
||||
}[] = [];
|
||||
monitorActions.retry = (id, credentials) => {
|
||||
retries.push({ id, credentials });
|
||||
};
|
||||
let revision = 0;
|
||||
let latestSnapshot: MonitorSnapshot;
|
||||
function seed(
|
||||
count = 2,
|
||||
view: "list" | "charts" = "charts",
|
||||
locale = "zh-CN",
|
||||
pointCount = 12,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const hosts: Host[] = Array.from({ length: count }, (_, i) => ({
|
||||
id: `host-${i}`,
|
||||
label: `Production server ${String(i + 1).padStart(4, "0")}`,
|
||||
hostname: `10.24.${Math.floor(i / 250)}.${(i % 250) + 1}`,
|
||||
username: "fixture",
|
||||
port: 22,
|
||||
os: "linux",
|
||||
distro: "ubuntu",
|
||||
group: "Production",
|
||||
tags:
|
||||
i === 1
|
||||
? []
|
||||
: [
|
||||
"production",
|
||||
"database",
|
||||
"critical",
|
||||
"east-region",
|
||||
"long-tag-value-for-overflow",
|
||||
],
|
||||
notes:
|
||||
i === 1
|
||||
? ""
|
||||
: "Primary database cluster. Contact the on-call engineer before restarting.\nMaintenance window: Sunday 02:00–04:00.",
|
||||
createdAt: 1,
|
||||
}));
|
||||
writeMonitorPanels([
|
||||
{
|
||||
id: "fixture",
|
||||
name: "Fleet overview",
|
||||
description: "",
|
||||
hostIds: hosts.map((h) => h.id),
|
||||
view,
|
||||
interval: 5,
|
||||
range: 15,
|
||||
sort: "manual",
|
||||
},
|
||||
]);
|
||||
const snapshot: MonitorSnapshot = {
|
||||
historyMode: "replace",
|
||||
runtimeId: "monitor-ui-fixture",
|
||||
epoch: now,
|
||||
takenAt: now,
|
||||
running: ["fixture"],
|
||||
count: 1,
|
||||
hostCount: count,
|
||||
activeSessions: [],
|
||||
startedAt: { fixture: now - Math.max(60000, pointCount * 1000) },
|
||||
hosts: Object.fromEntries(
|
||||
hosts.map((host, i) => [
|
||||
host.id,
|
||||
{
|
||||
historyId: host.id,
|
||||
status: "live",
|
||||
at: now,
|
||||
stats: {
|
||||
cpuPerCore: [],
|
||||
memFree: 12288,
|
||||
memBuffers: 0,
|
||||
memCached: 0,
|
||||
swapTotal: 0,
|
||||
swapUsed: 0,
|
||||
topProcesses: [],
|
||||
netInterfaces: [],
|
||||
cpu: 23 + (i % 60),
|
||||
cpuCores: 8,
|
||||
memUsed: 4096,
|
||||
memTotal: 16384,
|
||||
diskPercent: 35,
|
||||
diskUsed: 35,
|
||||
diskTotal: 100,
|
||||
disks: Array.from({ length: i === 2 ? 10 : 2 }, (_, j) => ({
|
||||
mountPoint: j ? `/data-${j}` : "/",
|
||||
used: 35,
|
||||
total: 100 + j * 50,
|
||||
percent: 35,
|
||||
})),
|
||||
netRxSpeed: 240000,
|
||||
netTxSpeed: 72000,
|
||||
networkReady: true,
|
||||
latencyMs:
|
||||
i === 1 || i === 5
|
||||
? null
|
||||
: i === 2
|
||||
? 180
|
||||
: i === 3
|
||||
? 450
|
||||
: i === 4
|
||||
? 0
|
||||
: 12 + (i % 30),
|
||||
responseLatencyMs: i === 5 ? 145 : null,
|
||||
gpu: i === 0 ? 45 : i === 2 ? 0 : null,
|
||||
gpuName:
|
||||
i === 0
|
||||
? "NVIDIA GeForce RTX 4090"
|
||||
: i === 2
|
||||
? "NVIDIA Tesla T4"
|
||||
: null,
|
||||
gpuMemUsed: i === 0 ? 8192 : i === 2 ? 0 : null,
|
||||
gpuMemTotal: i === 0 ? 24576 : i === 2 ? 16384 : null,
|
||||
},
|
||||
points: Array.from({ length: pointCount }, (_, j) => ({
|
||||
at: now - (pointCount - 1 - j) * (pointCount === 12 ? 5000 : 1000),
|
||||
cpu: 20 + ((i + j) % 40),
|
||||
memory: 25,
|
||||
disk: 35,
|
||||
rx: 200000 + j * 1000,
|
||||
tx: 70000 + j * 800,
|
||||
})),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
latestSnapshot = snapshot;
|
||||
publishMonitorSnapshot(snapshot);
|
||||
connections = [];
|
||||
retries = [];
|
||||
root.render(
|
||||
<I18nProvider locale={locale}>
|
||||
<div className="flex h-screen min-w-0 bg-background text-foreground">
|
||||
<MonitorPanels
|
||||
key={++revision}
|
||||
hosts={hosts}
|
||||
customGroups={["Production"]}
|
||||
onConnect={(host) => {
|
||||
connections.push(host.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
Object.assign(window, {
|
||||
monitorFixture: {
|
||||
seed,
|
||||
connections: () => connections,
|
||||
retries: () => retries,
|
||||
status: (id: string, status: string, age = 0) => {
|
||||
latestSnapshot = {
|
||||
...latestSnapshot,
|
||||
takenAt: Date.now(),
|
||||
hosts: {
|
||||
...latestSnapshot.hosts,
|
||||
[id]: {
|
||||
...latestSnapshot.hosts[id],
|
||||
at: Date.now() - age,
|
||||
status: status as MonitorSnapshot["hosts"][string]["status"],
|
||||
},
|
||||
},
|
||||
};
|
||||
publishMonitorSnapshot(latestSnapshot);
|
||||
},
|
||||
refresh: () => {
|
||||
latestSnapshot = {
|
||||
...latestSnapshot,
|
||||
takenAt: Date.now(),
|
||||
hosts: Object.fromEntries(
|
||||
Object.entries(latestSnapshot.hosts).map(([id, state]) => [
|
||||
id,
|
||||
{ ...state, at: Date.now(), points: [...state.points] },
|
||||
]),
|
||||
),
|
||||
};
|
||||
publishMonitorSnapshot(latestSnapshot);
|
||||
},
|
||||
},
|
||||
});
|
||||
seed(Number(new URLSearchParams(location.search).get("count")) || 2);
|
||||
@@ -1,840 +0,0 @@
|
||||
"use strict";
|
||||
// Real production monitoring components, isolated storage and deterministic readings.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const temp = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const profile = fs.mkdtempSync(temp.getTempFilePath("monitor-ui-"));
|
||||
app.setPath("userData", profile);
|
||||
let owner, vite;
|
||||
const errors = [];
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const run = (code) => owner.webContents.executeJavaScript(code);
|
||||
async function until(read, label) {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
if (await read()) return;
|
||||
await wait(30);
|
||||
}
|
||||
assert.ok(await read(), label);
|
||||
}
|
||||
async function click(selector) {
|
||||
const rect = await run(
|
||||
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e?.scrollIntoView({block:'nearest'});const r=e?.getBoundingClientRect();return r&&{x:r.x+r.width/2,y:r.y+r.height/2}})()`,
|
||||
);
|
||||
assert.ok(rect, selector);
|
||||
owner.webContents.focus();
|
||||
for (const type of ["mouseDown", "mouseUp"])
|
||||
owner.webContents.sendInputEvent({
|
||||
type,
|
||||
button: "left",
|
||||
clickCount: 1,
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
});
|
||||
await run(
|
||||
"new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))",
|
||||
);
|
||||
}
|
||||
const watchdog = setTimeout(() => {
|
||||
console.error("Monitoring UI acceptance timed out");
|
||||
app.exit(1);
|
||||
}, 90000);
|
||||
async function main() {
|
||||
const { createServer } = await import("vite");
|
||||
vite = await createServer({
|
||||
root: path.resolve(__dirname, ".."),
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
logLevel: "error",
|
||||
});
|
||||
await vite.listen();
|
||||
owner = new BrowserWindow({ width: 1280, height: 900, show: true });
|
||||
owner.webContents.on("console-message", (event) => {
|
||||
if (event.level === "error" || event.level >= 3) errors.push(event.message);
|
||||
});
|
||||
await owner.loadURL(
|
||||
`http://127.0.0.1:${vite.httpServer.address().port}/scripts/fixtures/monitor-panels.html`,
|
||||
);
|
||||
await until(
|
||||
() => run('document.querySelectorAll("[data-monitor-host]").length > 0'),
|
||||
"monitoring renders",
|
||||
);
|
||||
const screenshot = async (name) => {
|
||||
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
|
||||
await wait(200);
|
||||
await run(
|
||||
"document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(profile, name),
|
||||
(await owner.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
};
|
||||
const count = () =>
|
||||
run('document.querySelectorAll("[data-monitor-host]").length');
|
||||
const scroll = (selector) =>
|
||||
run(
|
||||
`(()=>{const e=document.querySelector('[data-section="vault-monitor-list"]');e.scrollTop=${selector};})()`,
|
||||
);
|
||||
const hover = async (selector) => {
|
||||
owner.focus();
|
||||
owner.webContents.focus();
|
||||
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
|
||||
await run(
|
||||
"new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))",
|
||||
);
|
||||
const point = await run(
|
||||
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`,
|
||||
);
|
||||
owner.webContents.sendInputEvent({
|
||||
type: "mouseMove",
|
||||
x: Math.round(point.x),
|
||||
y: Math.round(point.y),
|
||||
});
|
||||
// Tooltip grace areas update on movement; model the following mouse event
|
||||
// that a real pointer produces rather than teleporting between triggers.
|
||||
await run("new Promise(r=>requestAnimationFrame(r))");
|
||||
owner.webContents.sendInputEvent({
|
||||
type: "mouseMove",
|
||||
x: Math.round(point.x + 1),
|
||||
y: Math.round(point.y),
|
||||
});
|
||||
};
|
||||
const checkActionContrast = async (view) => {
|
||||
const keys = ["accent", "accent-foreground"];
|
||||
const saved = await run(
|
||||
`Object.fromEntries(${JSON.stringify(["accent", "accent-foreground"])}.map(k=>[k,document.documentElement.style.getPropertyValue('--'+k)]))`,
|
||||
);
|
||||
await run(
|
||||
`document.documentElement.style.setProperty('--accent','170 70% 42%');document.documentElement.style.setProperty('--accent-foreground','210 20% 12%')`,
|
||||
);
|
||||
await hover("[data-monitor-terminal=host-0]");
|
||||
await wait(200);
|
||||
const colors = await run(
|
||||
`(()=>{const e=document.querySelector('[data-monitor-terminal=host-0]');return {background:getComputedStyle(e).backgroundColor,icon:getComputedStyle(e.querySelector('svg')).color}})()`,
|
||||
);
|
||||
const luminance = (color) =>
|
||||
color
|
||||
.match(/[\d.]+/g)
|
||||
.slice(0, 3)
|
||||
.map(Number)
|
||||
.map((v) => v / 255)
|
||||
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
||||
.reduce((n, v, i) => n + v * [0.2126, 0.7152, 0.0722][i], 0);
|
||||
const a = luminance(colors.background),
|
||||
b = luminance(colors.icon);
|
||||
assert.ok(
|
||||
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) >= 3,
|
||||
`${view} terminal icon stays visible on accent hover: ${JSON.stringify(colors)}`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(profile, `monitor-${view}-action-hover.png`),
|
||||
(await owner.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
for (const key of keys)
|
||||
await run(
|
||||
`document.documentElement.style.setProperty('--'+${JSON.stringify(key)},${JSON.stringify(saved[key])})`,
|
||||
);
|
||||
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
|
||||
};
|
||||
app.focus({ steal: true });
|
||||
owner.focus();
|
||||
owner.webContents.focus();
|
||||
assert.deepEqual(
|
||||
await run(
|
||||
'Array.from(document.querySelectorAll("[data-monitor-host]")).map(e=>e.getBoundingClientRect().height)',
|
||||
),
|
||||
[368, 368],
|
||||
);
|
||||
assert.equal(
|
||||
await run(
|
||||
'document.querySelector("[data-monitor-host=host-0] [data-monitor-latency]").getAttribute("aria-label")',
|
||||
),
|
||||
"延迟: 12 ms",
|
||||
);
|
||||
assert.equal(
|
||||
await run(
|
||||
'document.querySelector("[data-monitor-host=host-1] [data-monitor-latency]").getAttribute("aria-label")',
|
||||
),
|
||||
"延迟: --",
|
||||
);
|
||||
assert.equal(
|
||||
await run('document.body.textContent.includes("读数只保留在内存")'),
|
||||
false,
|
||||
);
|
||||
await hover("[data-monitor-host=host-0] [data-monitor-notes]");
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("Maintenance window"))',
|
||||
),
|
||||
"full notes appear on hover",
|
||||
);
|
||||
await screenshot("notes-tooltip.png");
|
||||
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
|
||||
await checkActionContrast("charts");
|
||||
await click("[data-monitor-terminal=host-0]");
|
||||
assert.deepEqual(await run("window.monitorFixture.connections()"), [
|
||||
"host-0",
|
||||
]);
|
||||
assert.equal(
|
||||
await run(
|
||||
'!!document.querySelector("[data-section=vault-monitor-detail]")',
|
||||
),
|
||||
false,
|
||||
"terminal action does not open details",
|
||||
);
|
||||
await click("[data-monitor-host=host-0] > button:first-child");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-gpu]")'),
|
||||
"GPU details appear",
|
||||
);
|
||||
const gpu = await run(
|
||||
'document.querySelector("[data-monitor-gpu]").textContent',
|
||||
);
|
||||
for (const expected of [
|
||||
"NVIDIA GeForce RTX 4090",
|
||||
"45.0%",
|
||||
"8.0 GiB",
|
||||
"24.0 GiB",
|
||||
])
|
||||
assert.ok(gpu.includes(expected), expected);
|
||||
await screenshot("monitor-gpu.png");
|
||||
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
|
||||
await click("[data-monitor-host=host-1] > button:first-child");
|
||||
await until(
|
||||
() =>
|
||||
run('!!document.querySelector("[data-section=vault-monitor-detail]")'),
|
||||
"non-GPU details open",
|
||||
);
|
||||
assert.equal(
|
||||
await run('!!document.querySelector("[data-monitor-gpu]")'),
|
||||
false,
|
||||
"hosts without GPU have no empty GPU section",
|
||||
);
|
||||
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
|
||||
await run("window.monitorFixture.seed(6,'charts')");
|
||||
await until(
|
||||
() => run('document.querySelectorAll("[data-monitor-host]").length===6'),
|
||||
"signal examples render",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await run(
|
||||
'Array.from(document.querySelectorAll("[data-monitor-latency]")).map(e=>Number(e.dataset.monitorSignalBars))',
|
||||
),
|
||||
[4, 0, 2, 1, 4, 2],
|
||||
);
|
||||
assert.ok(
|
||||
await run(
|
||||
`(()=>{const row=document.querySelector('[data-monitor-host=host-0]');const a=row.querySelector('[data-monitor-latency]').getBoundingClientRect();const b=row.querySelector('[data-monitor-tags]').getBoundingClientRect();return Math.abs((a.top+a.bottom)/2-(b.top+b.bottom)/2)<1})()`,
|
||||
),
|
||||
"latency and tags are vertically centered",
|
||||
);
|
||||
await hover("[data-monitor-host=host-2] [data-monitor-latency]");
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("180 ms"))',
|
||||
),
|
||||
"signal tooltip has exact latency",
|
||||
);
|
||||
await hover("[data-monitor-host=host-5] [data-monitor-latency]");
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("响应延迟(含远端处理): 145 ms"))',
|
||||
),
|
||||
"disabled keepalive still shows response latency",
|
||||
);
|
||||
await screenshot("monitor-signals.png");
|
||||
await click("[data-monitor-host=host-2] > button:first-child");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-gpu]")'),
|
||||
"idle GPU details open",
|
||||
);
|
||||
const idleGpu = await run(
|
||||
'document.querySelector("[data-monitor-gpu]").textContent',
|
||||
);
|
||||
assert.ok(
|
||||
idleGpu.includes("0.0%") && idleGpu.includes("0 MiB"),
|
||||
"zero GPU readings remain visible",
|
||||
);
|
||||
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
|
||||
await run("window.monitorFixture.seed(1200,'charts')");
|
||||
await until(
|
||||
async () => (await count()) > 2 && (await count()) < 80,
|
||||
"charts render a bounded visible subset",
|
||||
);
|
||||
assert.equal(
|
||||
await run('document.querySelectorAll("[data-monitor-terminal]").length'),
|
||||
await count(),
|
||||
);
|
||||
assert.ok(
|
||||
await run(
|
||||
'Array.from(document.querySelectorAll("[data-monitor-host]")).every(e=>e.getBoundingClientRect().height===368)',
|
||||
),
|
||||
);
|
||||
await scroll("e.scrollHeight");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-host=host-1199]")'),
|
||||
"last card can be reached",
|
||||
);
|
||||
await click("[data-monitor-terminal=host-1199]");
|
||||
assert.deepEqual(
|
||||
await run("window.monitorFixture.connections()"),
|
||||
["host-1199"],
|
||||
"recycled card opens the correct host",
|
||||
);
|
||||
await click("button:has(.lucide-list)");
|
||||
await until(
|
||||
() => run('!!document.querySelector("table")'),
|
||||
"list view opens",
|
||||
);
|
||||
assert.ok((await count()) < 50, "list renders only visible rows");
|
||||
for (const position of [0, 450, 100000]) {
|
||||
await run(
|
||||
`document.querySelector('[data-section=vault-monitor-list]').scrollLeft=${position}`,
|
||||
);
|
||||
await run("new Promise(r=>requestAnimationFrame(r))");
|
||||
assert.ok(
|
||||
await run(
|
||||
`(()=>{const viewport=document.querySelector('[data-section=vault-monitor-list]');const action=document.querySelector('tbody tr td:last-child');return window.innerWidth-viewport.getBoundingClientRect().right>=15 && window.innerWidth-action.getBoundingClientRect().right>=15})()`,
|
||||
),
|
||||
"sticky actions retain the right page gutter at every horizontal scroll position",
|
||||
);
|
||||
const geometry = await run(
|
||||
`(()=>{const r=document.querySelector('tbody tr td:last-child').getBoundingClientRect();return {width:innerWidth,height:innerHeight,right:r.right,top:r.top,bottom:r.bottom}})()`,
|
||||
);
|
||||
const captured = await owner.webContents.capturePage();
|
||||
const bitmap = captured.toBitmap();
|
||||
const scale = Math.sqrt(
|
||||
bitmap.length / (4 * geometry.width * geometry.height),
|
||||
);
|
||||
const pixelWidth = Math.round(geometry.width * scale);
|
||||
const pixel = (x, y) => {
|
||||
const start =
|
||||
4 * (Math.floor(y * scale) * pixelWidth + Math.floor(x * scale));
|
||||
return [...bitmap.subarray(start, start + 4)];
|
||||
};
|
||||
const backdrop = pixel(geometry.width - 4, geometry.top + 1);
|
||||
assert.deepEqual(
|
||||
pixel(geometry.right - 1, geometry.top + 1),
|
||||
backdrop,
|
||||
`top-right corner stays visibly rounded at scroll ${position}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
pixel(geometry.right - 1, geometry.bottom - 2),
|
||||
backdrop,
|
||||
`bottom-right corner stays visibly rounded at scroll ${position}`,
|
||||
);
|
||||
assert.notDeepEqual(
|
||||
pixel(geometry.right - 2, (geometry.top + geometry.bottom) / 2),
|
||||
backdrop,
|
||||
"rounded action still paints the row background",
|
||||
);
|
||||
if (position === 0) await screenshot("monitor-rounded-row-start.png");
|
||||
}
|
||||
|
||||
await scroll("0");
|
||||
await checkActionContrast("list");
|
||||
const columnOrder = await run(
|
||||
'Array.from(document.querySelectorAll("th")).map(e=>e.dataset.monitorColumn)',
|
||||
);
|
||||
assert.deepEqual(columnOrder.slice(-2), ["status", "terminal"]);
|
||||
assert.equal(
|
||||
await run(
|
||||
`Array.from(document.querySelectorAll('th [role=separator]')).every(e=>getComputedStyle(e,'::after').backgroundColor==='rgba(0, 0, 0, 0)')`,
|
||||
),
|
||||
true,
|
||||
"column dividers are hidden at rest",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await run('document.querySelectorAll("th [role=separator]").length'),
|
||||
11,
|
||||
"every column can be resized",
|
||||
);
|
||||
await scroll("0");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-host=host-2]")'),
|
||||
"variable-height row is rendered",
|
||||
);
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
'document.querySelector("[data-monitor-host=host-2]").getBoundingClientRect().height>150',
|
||||
),
|
||||
"all partitions determine row height",
|
||||
);
|
||||
assert.ok(
|
||||
await run(
|
||||
`(()=>{const rows=[...document.querySelectorAll('tbody tr')];return rows.every((r,i)=>!i||r.getBoundingClientRect().top>=rows[i-1].getBoundingClientRect().bottom)})()`,
|
||||
),
|
||||
"variable-height rows never overlap",
|
||||
);
|
||||
await click("[data-monitor-terminal=host-0]");
|
||||
assert.deepEqual(await run("window.monitorFixture.connections()"), [
|
||||
"host-1199",
|
||||
"host-0",
|
||||
]);
|
||||
assert.equal(
|
||||
await run(
|
||||
'!!document.querySelector("[data-section=vault-monitor-detail]")',
|
||||
),
|
||||
false,
|
||||
);
|
||||
await run(
|
||||
"document.querySelector('[data-section=vault-monitor-list]').scrollLeft=0",
|
||||
);
|
||||
const widths = {};
|
||||
for (const key of [
|
||||
"host",
|
||||
"status",
|
||||
"latency",
|
||||
"tags",
|
||||
"notes",
|
||||
"cpu",
|
||||
"memory",
|
||||
"disks",
|
||||
"network",
|
||||
"updated",
|
||||
"terminal",
|
||||
]) {
|
||||
const selector = `[data-monitor-column=${key}] [role=separator]`;
|
||||
const point = await run(
|
||||
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest',inline:'center'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2,width:Number(e.getAttribute('aria-valuenow'))}})()`,
|
||||
);
|
||||
owner.webContents.sendInputEvent({
|
||||
type: "mouseDown",
|
||||
button: "left",
|
||||
clickCount: 1,
|
||||
x: Math.round(point.x),
|
||||
y: Math.round(point.y),
|
||||
});
|
||||
owner.webContents.sendInputEvent({
|
||||
type: "mouseMove",
|
||||
x: Math.round(point.x + 28),
|
||||
y: Math.round(point.y),
|
||||
});
|
||||
owner.webContents.sendInputEvent({
|
||||
type: "mouseUp",
|
||||
button: "left",
|
||||
clickCount: 1,
|
||||
x: Math.round(point.x + 28),
|
||||
y: Math.round(point.y),
|
||||
});
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
`Number(document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-valuenow'))>=${point.width + 27}`,
|
||||
),
|
||||
`${key} width responds to dragging`,
|
||||
);
|
||||
widths[key] = await run(
|
||||
`Number(document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-valuenow'))`,
|
||||
);
|
||||
assert.ok(
|
||||
await run(
|
||||
`(()=>{const h=document.querySelector('[data-monitor-column=${key}]');const i=[...h.parentElement.children].indexOf(h);const cell=document.querySelector('tbody tr').children[i];return Math.abs(h.getBoundingClientRect().width-cell.getBoundingClientRect().width)<1})()`,
|
||||
),
|
||||
"header and body column widths stay aligned",
|
||||
);
|
||||
}
|
||||
await run(
|
||||
"document.querySelector('[data-monitor-column=host] [role=separator]').focus()",
|
||||
);
|
||||
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Right" });
|
||||
widths.host += 8;
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
`Number(document.querySelector('[data-monitor-column=host] [role=separator]').getAttribute('aria-valuenow'))===${widths.host}`,
|
||||
),
|
||||
"column size can also be adjusted by keyboard",
|
||||
);
|
||||
await run("window.monitorFixture.seed(1200,'list')");
|
||||
await until(() => run('!!document.querySelector("table")'), "list remounts");
|
||||
for (const [key, width] of Object.entries(widths))
|
||||
assert.equal(
|
||||
await run(
|
||||
`Number(document.querySelector('[data-monitor-column=${key}] [role=separator]').getAttribute('aria-valuenow'))`,
|
||||
),
|
||||
width,
|
||||
"column widths survive remounting",
|
||||
);
|
||||
await scroll("e.scrollHeight");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-host=host-1199]")'),
|
||||
"last table row can be reached",
|
||||
);
|
||||
await click("[data-monitor-terminal=host-1199]");
|
||||
assert.deepEqual(
|
||||
await run("window.monitorFixture.connections()"),
|
||||
["host-1199"],
|
||||
"recycled row opens the correct host",
|
||||
);
|
||||
await run(
|
||||
`(()=>{const e=document.querySelector('input[placeholder="搜索主机、分组、标签或备注"]');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(e,'1199');e.dispatchEvent(new Event('input',{bubbles:true}));})()`,
|
||||
);
|
||||
await until(
|
||||
async () => (await count()) === 1,
|
||||
"filtering at the bottom does not leave a blank virtual viewport",
|
||||
);
|
||||
assert.ok(
|
||||
await run('document.body.textContent.includes("Production server 1199")'),
|
||||
);
|
||||
await run("window.monitorFixture.seed(40,'list')");
|
||||
await until(
|
||||
() => run('document.querySelectorAll("tbody tr").length>2'),
|
||||
"unfiltered list restores",
|
||||
);
|
||||
await screenshot("monitor-list.png");
|
||||
for (const width of [460, 1000]) {
|
||||
owner.setSize(width, 900);
|
||||
await wait(100);
|
||||
await run("window.monitorFixture.seed(40,'charts')");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-host]")'),
|
||||
"cards fit resized viewport",
|
||||
);
|
||||
assert.ok(
|
||||
await run(
|
||||
'Array.from(document.querySelectorAll("[data-monitor-host]")).every(e=>e.getBoundingClientRect().height===368)',
|
||||
),
|
||||
"card height is fixed at all widths",
|
||||
);
|
||||
assert.ok(
|
||||
await run(
|
||||
"(()=>{const e=document.querySelector('[data-section=vault-monitor-list]');return e.scrollWidth<=e.clientWidth+1})()",
|
||||
),
|
||||
"cards do not overflow horizontally",
|
||||
);
|
||||
}
|
||||
await screenshot("monitor-charts.png");
|
||||
for (const locale of ["en", "zh-TW", "es", "ru"]) {
|
||||
await run(
|
||||
`window.monitorFixture.seed(40,'list',${JSON.stringify(locale)})`,
|
||||
);
|
||||
await until(
|
||||
() => run('!!document.querySelector("table")'),
|
||||
"translated table renders",
|
||||
);
|
||||
assert.equal(
|
||||
await run('document.body.textContent.includes("monitor.")'),
|
||||
false,
|
||||
"no missing translation keys",
|
||||
);
|
||||
}
|
||||
|
||||
const openColumnMenu = async () => {
|
||||
const point = await run(
|
||||
`(()=>{document.querySelector('[data-section=vault-monitor-list]').scrollLeft=0;const r=document.querySelector('thead').getBoundingClientRect();return {x:Math.round(r.left+40),y:Math.round(r.top+20)}})()`,
|
||||
);
|
||||
for (const type of ["mouseDown", "mouseUp"])
|
||||
owner.webContents.sendInputEvent({
|
||||
type,
|
||||
button: "right",
|
||||
clickCount: 1,
|
||||
...point,
|
||||
});
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-column-menu]")'),
|
||||
"header right-click opens column choices",
|
||||
);
|
||||
};
|
||||
await run("window.monitorFixture.seed(8,'list','zh-CN')");
|
||||
await until(
|
||||
() => run('document.querySelectorAll("th").length===11'),
|
||||
"all columns start visible",
|
||||
);
|
||||
await openColumnMenu();
|
||||
assert.equal(
|
||||
await run(
|
||||
`['host','terminal'].every(k=>{const e=document.querySelector('[data-monitor-toggle-column='+k+']');return e.getAttribute('aria-checked')==='true'&&e.getAttribute('aria-disabled')==='true'})`,
|
||||
),
|
||||
true,
|
||||
"host and terminal cannot be hidden",
|
||||
);
|
||||
const optional = columnOrder.filter((k) => !["host", "terminal"].includes(k));
|
||||
for (const key of optional)
|
||||
await click(`[data-monitor-toggle-column=${key}]`);
|
||||
assert.deepEqual(
|
||||
await run(
|
||||
'[...document.querySelectorAll("th")].map(e=>e.dataset.monitorColumn)',
|
||||
),
|
||||
["host", "terminal"],
|
||||
);
|
||||
assert.ok(
|
||||
await run(
|
||||
'[...document.querySelectorAll("tbody tr")].every(e=>e.children.length===2)',
|
||||
),
|
||||
"hidden headings and row cells stay aligned",
|
||||
);
|
||||
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
|
||||
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
|
||||
await run("window.monitorFixture.seed(8,'list','zh-CN')");
|
||||
await until(
|
||||
() => run('document.querySelectorAll("th").length===2'),
|
||||
"column visibility survives reopening",
|
||||
);
|
||||
const hostWidthBefore = await run(
|
||||
'document.querySelector("tbody tr td").getBoundingClientRect().width',
|
||||
);
|
||||
await run(
|
||||
'document.querySelector("[data-monitor-column=host] [role=separator]").focus()',
|
||||
);
|
||||
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Right" });
|
||||
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Right" });
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
`document.querySelector('tbody tr td').getBoundingClientRect().width===${hostWidthBefore + 8}`,
|
||||
),
|
||||
"host visibly resizes after other columns are hidden",
|
||||
);
|
||||
await openColumnMenu();
|
||||
for (const key of optional)
|
||||
await click(`[data-monitor-toggle-column=${key}]`);
|
||||
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
|
||||
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
|
||||
await until(
|
||||
() => run('document.querySelectorAll("th").length===11'),
|
||||
"hidden columns can be restored",
|
||||
);
|
||||
for (const view of ["list", "charts"]) {
|
||||
await run(`window.monitorFixture.seed(3,'${view}','zh-CN')`);
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-host=host-0]")'),
|
||||
"status fixtures render",
|
||||
);
|
||||
for (const status of [
|
||||
"login",
|
||||
"error",
|
||||
"disconnected",
|
||||
"unsupported",
|
||||
"queued",
|
||||
"connecting",
|
||||
"limit",
|
||||
]) {
|
||||
await run(`window.monitorFixture.status('host-0','${status}')`);
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='true'`,
|
||||
),
|
||||
"unavailable host has disabled detail action",
|
||||
);
|
||||
assert.equal(
|
||||
await run(
|
||||
"getComputedStyle(document.querySelector('[data-monitor-host=host-0]')).opacity",
|
||||
),
|
||||
"0.5",
|
||||
"unavailable host is dimmed",
|
||||
);
|
||||
await click("[data-monitor-host=host-0] button");
|
||||
assert.equal(
|
||||
await run(
|
||||
'!!document.querySelector("[data-section=vault-monitor-detail]")',
|
||||
),
|
||||
false,
|
||||
"unavailable host does not open details",
|
||||
);
|
||||
}
|
||||
await run("window.monitorFixture.status('host-0','live',60000)");
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='true'`,
|
||||
),
|
||||
"stale readings cannot open details",
|
||||
);
|
||||
await click("[data-monitor-host=host-0] button");
|
||||
assert.equal(
|
||||
await run(
|
||||
'!!document.querySelector("[data-section=vault-monitor-detail]")',
|
||||
),
|
||||
false,
|
||||
"stale host stays unavailable",
|
||||
);
|
||||
await click("[data-monitor-terminal=host-0]");
|
||||
assert.deepEqual(
|
||||
await run("window.monitorFixture.connections()"),
|
||||
["host-0"],
|
||||
"login host can still open a terminal",
|
||||
);
|
||||
await run("window.monitorFixture.status('host-0','login')");
|
||||
const recoveryPoint = await run(
|
||||
`(()=>{const e=document.querySelector('[data-monitor-host=host-0]');e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:Math.round(r.left+30),y:Math.round(r.top+20)}})()`,
|
||||
);
|
||||
for (const type of ["mouseDown", "mouseUp"])
|
||||
owner.webContents.sendInputEvent({
|
||||
type,
|
||||
button: "right",
|
||||
clickCount: 1,
|
||||
...recoveryPoint,
|
||||
});
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-retry=host-0]")'),
|
||||
"unavailable host context menu offers recovery",
|
||||
);
|
||||
await click("[data-monitor-retry=host-0]");
|
||||
await until(
|
||||
() => run('!!document.querySelector("#monitor-login-password")'),
|
||||
"recovery dialog opens separately from details",
|
||||
);
|
||||
assert.equal(
|
||||
await run(
|
||||
'!!document.querySelector("[data-section=vault-monitor-detail]")',
|
||||
),
|
||||
false,
|
||||
);
|
||||
await click("#monitor-login-password");
|
||||
owner.webContents.insertText("fixture-password");
|
||||
await click("[role=dialog] button[type=submit]");
|
||||
await until(
|
||||
() => run('!document.querySelector("#monitor-login-password")'),
|
||||
"recovery clears password inputs",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await run("window.monitorFixture.retries()"),
|
||||
[
|
||||
{
|
||||
id: "host-0",
|
||||
credentials: { username: "fixture", password: "fixture-password" },
|
||||
},
|
||||
],
|
||||
"recovery forwards one-time credentials to the existing monitor retry path",
|
||||
);
|
||||
await run("window.monitorFixture.status('host-0','live')");
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='false'`,
|
||||
),
|
||||
"recovered host enables details",
|
||||
);
|
||||
await click("[data-monitor-host=host-0] button");
|
||||
await until(
|
||||
() =>
|
||||
run('!!document.querySelector("[data-section=vault-monitor-detail]")'),
|
||||
"live host opens details",
|
||||
);
|
||||
await run("window.monitorFixture.status('host-0','login')");
|
||||
await until(
|
||||
() =>
|
||||
run('!document.querySelector("[data-section=vault-monitor-detail]")'),
|
||||
"unavailable details close immediately",
|
||||
);
|
||||
}
|
||||
|
||||
owner.setSize(1280, 650);
|
||||
await run("window.monitorFixture.seed(3,'charts','zh-CN',1800)");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-host=host-0]")'),
|
||||
"long monitoring history renders",
|
||||
);
|
||||
await click("[data-monitor-host=host-0] button");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-aside-scroll-viewport]")'),
|
||||
"native monitoring detail scroll opens",
|
||||
);
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
`document.querySelector('[data-section=vault-monitor-detail]').getAnimations().every(a=>a.playState==='finished')`,
|
||||
),
|
||||
"sidebar opening animation finishes before wheel input",
|
||||
);
|
||||
const wheelPoint = await run(
|
||||
`(()=>{const v=document.querySelector('[data-aside-scroll-viewport]');const r=v.getBoundingClientRect();window.monitorRefreshTimer=setInterval(()=>window.monitorFixture.refresh(),200);window.monitorWheelProbe={events:0,blocking:0};window.monitorWheelListener=e=>{window.monitorWheelProbe.events++;if(e.cancelable)window.monitorWheelProbe.blocking++;};document.addEventListener('wheel',window.monitorWheelListener,{passive:true});return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+180)}})()`,
|
||||
);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
owner.webContents.sendInputEvent({
|
||||
type: "mouseWheel",
|
||||
...wheelPoint,
|
||||
deltaY: -12,
|
||||
deltaX: 0,
|
||||
canScroll: true,
|
||||
});
|
||||
await wait(17);
|
||||
}
|
||||
await until(
|
||||
() =>
|
||||
run(
|
||||
"document.querySelector('[data-aside-scroll-viewport]').scrollTop>200",
|
||||
),
|
||||
"wheel reaches lower detail sections with a half-hour history",
|
||||
);
|
||||
const wheelResult = await run("window.monitorWheelProbe");
|
||||
assert.ok(
|
||||
wheelResult.events > 40,
|
||||
"real wheel input reached monitoring details",
|
||||
);
|
||||
assert.equal(
|
||||
wheelResult.blocking,
|
||||
0,
|
||||
"detail wheel input does not wait for a cancellable handler",
|
||||
);
|
||||
assert.equal(
|
||||
await run(
|
||||
"document.querySelector('[data-section=vault-monitor-list]').scrollTop",
|
||||
),
|
||||
0,
|
||||
"sidebar scroll leaves host collection in place",
|
||||
);
|
||||
await screenshot("monitor-native-detail-scroll.png");
|
||||
await run(
|
||||
"clearInterval(window.monitorRefreshTimer);document.removeEventListener('wheel',window.monitorWheelListener)",
|
||||
);
|
||||
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
|
||||
owner.setSize(1280, 900);
|
||||
await run(
|
||||
`(()=>{document.documentElement.classList.remove('dark');for(const [k,v] of Object.entries({background:'0 0% 100%',foreground:'210 20% 15%',card:'210 20% 97%',secondary:'210 20% 94%',muted:'210 20% 95%','muted-foreground':'210 10% 45%',border:'210 20% 88%',popover:'0 0% 100%','popover-foreground':'210 20% 15%'}))document.documentElement.style.setProperty('--'+k,v)})()`,
|
||||
);
|
||||
await run("window.monitorFixture.seed(40,'charts','zh-CN')");
|
||||
await until(
|
||||
() => run('!!document.querySelector("[data-monitor-host]")'),
|
||||
"light cards render",
|
||||
);
|
||||
await screenshot("monitor-light.png");
|
||||
assert.deepEqual(errors, [], "no renderer errors");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
virtualHosts: 1200,
|
||||
fixedCardHeight: 368,
|
||||
terminalActions: true,
|
||||
notesTooltip: true,
|
||||
latencySignals: true,
|
||||
gpuDetails: true,
|
||||
statusBeforeAction: true,
|
||||
allColumnsResize: true,
|
||||
persistedWidths: true,
|
||||
visibleColumns: true,
|
||||
unavailableHosts: true,
|
||||
nativeDetailScroll: true,
|
||||
variableRowHeights: true,
|
||||
filterAfterScroll: true,
|
||||
locales: 5,
|
||||
profile,
|
||||
}),
|
||||
);
|
||||
}
|
||||
app
|
||||
.whenReady()
|
||||
.then(main)
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
if (owner && !owner.isDestroyed())
|
||||
fs.writeFileSync(
|
||||
path.join(profile, "failure.png"),
|
||||
(await owner.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
})
|
||||
.finally(async () => {
|
||||
clearTimeout(watchdog);
|
||||
owner?.destroy();
|
||||
await vite?.close();
|
||||
app.exit(process.exitCode || 0);
|
||||
});
|
||||
-1
@@ -327,7 +327,6 @@ declare global {
|
||||
netRxSpeed: number; // Total network receive speed (bytes/sec)
|
||||
netTxSpeed: number; // Total network transmit speed (bytes/sec)
|
||||
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
|
||||
responseLatencyMs?: number | null; // Existing stats exec response, including remote channel setup
|
||||
netInterfaces: Array<{ // Per-interface network stats
|
||||
name: string; // Interface name (e.g., eth0, ens33)
|
||||
rxBytes: number; // Total received bytes
|
||||
|
||||
+1
-15
@@ -1,11 +1,6 @@
|
||||
import type { S3Config, SyncedFile, WebDAVConfig } from "../../domain/sync";
|
||||
import type { AppLockSettings } from "../../domain/appLock";
|
||||
|
||||
export interface HostBrowserPanelSnapshotLayer {
|
||||
image: string;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
type AppLockRuntimeReason = 'startup' | 'idle' | 'manual' | 'background' | null;
|
||||
|
||||
interface AppLockRuntimeState {
|
||||
@@ -107,17 +102,8 @@ declare global {
|
||||
|
||||
// Settings window
|
||||
openSettingsWindow?(): Promise<boolean>;
|
||||
setHostBrowserPanelLayout?(options: { browserId: string; visible: boolean; occluded?: boolean; theme?: 'dark' | 'light'; themeTokens?: Record<string, string>; bounds?: { x: number; y: number; width: number; height: number } }): Promise<boolean | { layers: HostBrowserPanelSnapshotLayer[] }>;
|
||||
setHostBrowserPanelLayout?(options: { browserId: string; visible: boolean; theme?: 'dark' | 'light'; themeTokens?: Record<string, string>; bounds?: { x: number; y: number; width: number; height: number } }): Promise<boolean>;
|
||||
closeHostBrowserPanel?(browserId: string): Promise<boolean>;
|
||||
getHostBrowserPanelState?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserState>;
|
||||
commandHostBrowserPanel?(browserId: string, command: import('../../domain/hostBrowser').HostBrowserCommand): Promise<import('../../domain/hostBrowser').HostBrowserState>;
|
||||
listHostBrowserPanelBookmarks?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserBookmark[]>;
|
||||
saveHostBrowserPanelBookmark?(browserId: string, bookmark: Partial<import('../../domain/browserBookmark').BrowserBookmark>): Promise<import('../../domain/browserBookmark').BrowserBookmark>;
|
||||
removeHostBrowserPanelBookmark?(browserId: string, id: string): Promise<boolean>;
|
||||
listHostBrowserPanelDownloads?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserDownload[]>;
|
||||
commandHostBrowserPanelDownload?(browserId: string, command: { type: 'open-folder' | 'clear' | 'cancel' | 'remove' | 'open' | 'reveal' | 'save-as'; id?: string }): Promise<boolean>;
|
||||
onHostBrowserPanelEvent?(callback: (event: import('../../domain/hostBrowser').HostBrowserPanelEvent) => void): () => void;
|
||||
|
||||
openHostBrowserUrl?(options: { browserId: string; url: string }): Promise<boolean>;
|
||||
requestHostBrowserPanel?(request: { host: import('../../domain/models').Host; sessionId?: string; url?: string }): Promise<boolean>;
|
||||
onHostBrowserPanelRequest?(callback: (request: { host: import('../../domain/models').Host; sessionId?: string; url?: string }) => void): () => void;
|
||||
|
||||
Reference in new issue
Block a user