mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 18:32:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd111b1264 | ||
|
|
891bce6551 | ||
|
|
cfc4984ad2 | ||
|
|
2f0249f74c | ||
|
|
0a5e3ef57e | ||
|
|
45ef6c2c9f | ||
|
|
fd0fe8bbdd | ||
|
|
e6e7cb8140 | ||
|
|
bcbe93c831 | ||
|
|
2f2ef273c4 | ||
|
|
16234dfc44 | ||
|
|
668744a936 | ||
|
|
bd02de4157 | ||
|
|
ee88adf854 | ||
|
|
98dea3e370 | ||
|
|
afba5acf4b | ||
|
|
4176acd2b7 | ||
|
|
6650c3c07d | ||
|
|
6f81a62e21 | ||
|
|
65ffa11b73 | ||
|
|
c310931713 | ||
|
|
82286166cf | ||
|
|
8a0a0ce2ca | ||
|
|
63b59504e2 | ||
|
|
673843149b | ||
|
|
9ee4531483 | ||
|
|
3a8a63273b | ||
|
|
c12d338000 | ||
|
|
2662a9f196 | ||
|
|
dabcec4c2c | ||
|
|
c54b711793 | ||
|
|
6e5896593b | ||
|
|
9d941d0594 | ||
|
|
e38b372d1f | ||
|
|
189db57625 | ||
|
|
6736f95532 | ||
|
|
fc641cb304 | ||
|
|
27dc1c62af | ||
|
|
ef0a07e560 | ||
|
|
fb3396e389 | ||
|
|
aced2a99f8 | ||
|
|
cfb534615c | ||
|
|
409fd30969 | ||
|
|
cd284ae4f3 | ||
|
|
be3658b24f | ||
|
|
072377777f | ||
|
|
27b62b2372 | ||
|
|
627e652f56 | ||
|
|
17c2eb6346 | ||
|
|
01102e2d66 | ||
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
27ec58fa40 | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
dfc9ed7f61 | ||
|
|
2c2d7e0b55 | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
d1cd072502 | ||
|
|
60820acf9a | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
60e057318f | ||
|
|
7408e960cc | ||
|
|
9cd756f44b | ||
|
|
cd12b04647 | ||
|
|
9671468412 | ||
|
|
e4f9303de5 | ||
|
|
c8a393f12b | ||
|
|
7114288d2f | ||
|
|
0382c089e1 | ||
|
|
e9f5bcc6dc | ||
|
|
f8c7dd44db | ||
|
|
a47de3cb52 | ||
|
|
0ccea42785 |
No files matched your search
@@ -21,6 +21,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
@@ -50,11 +52,18 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test terminal decoration performance
|
||||
- name: Test terminal keyword highlight performance
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
|
||||
|
||||
- name: Test terminal keyword highlight throughput
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
|
||||
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -200,6 +200,7 @@ export function AppSideEffects() {
|
||||
readPersistedHosts,
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
commitVaultGroupMutation,
|
||||
} = vaultState;
|
||||
|
||||
const hostsRef = useRef(hosts);
|
||||
@@ -1283,6 +1284,7 @@ export function AppSideEffects() {
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
updateManagedSources,
|
||||
commitVaultGroupMutation,
|
||||
updatePortForwardingRules: importPortForwardingRules,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
|
||||
@@ -258,7 +258,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme,
|
||||
@@ -592,6 +592,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
onReadPersistedManagedSources={readPersistedManagedSources}
|
||||
onCommitVaultImportTransaction={commitVaultImportTransaction}
|
||||
onCommitVaultGroupMutation={commitVaultGroupMutation}
|
||||
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||
onUnmanageSource={unmanageSource}
|
||||
|
||||
@@ -38,6 +38,7 @@ export function VaultHost() {
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
convertKnownHostToHost: actions?.convertKnownHostToHost,
|
||||
customGroups: vault.customGroups,
|
||||
deepLinkHostDraft: local.deepLinkHostDraft,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
@@ -138,6 +139,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
@@ -150,6 +152,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
addShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
convertKnownHostToHost,
|
||||
importOrReuseKey,
|
||||
readPersistedHosts,
|
||||
|
||||
@@ -53,6 +53,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const enScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
|
||||
'scripts.editor.lineCount': '{count} lines',
|
||||
'scripts.editor.resize': 'Resize editor',
|
||||
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
|
||||
'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.',
|
||||
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
|
||||
'scripts.targets.currentHostMismatch': 'This script is not assigned to the current host.',
|
||||
'hostDetails.automation.groupScripts': 'Inherited group scripts',
|
||||
'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.',
|
||||
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
|
||||
@@ -659,6 +659,9 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.selectHosts': 'Hosts',
|
||||
'snippets.targets.selectGroups': 'Groups',
|
||||
'snippets.targets.noGroups': 'No groups found',
|
||||
'snippets.targets.allHosts': 'Apply to all hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
|
||||
'ai.providers.apiKey.decrypting': 'Расшифровка...',
|
||||
'ai.providers.baseUrl': 'Базовый URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const ruScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.',
|
||||
'scripts.editor.lineCount': '{count} строк',
|
||||
'scripts.editor.resize': 'Изменить размер редактора',
|
||||
'scripts.targets.hint': 'Автоматические триггеры применяются к выбранным хостам или ко всем хостам, если включена эта настройка.',
|
||||
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
|
||||
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
|
||||
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
|
||||
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
|
||||
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
|
||||
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
|
||||
@@ -681,6 +681,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.field.variableDefault': 'по умолчанию {value}',
|
||||
'snippets.targets.title': 'Цели',
|
||||
'snippets.targets.add': 'Добавить цели',
|
||||
'snippets.targets.selectHosts': 'Хосты',
|
||||
'snippets.targets.selectGroups': 'Группы',
|
||||
'snippets.targets.noGroups': 'Группы не найдены',
|
||||
'snippets.history.title': 'История оболочки',
|
||||
'snippets.history.subtitle': '{count} команд',
|
||||
'snippets.history.emptyTitle': 'История оболочки пока пуста',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '调整编辑器高度',
|
||||
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
|
||||
'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。',
|
||||
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
|
||||
'scripts.targets.currentHostMismatch': '此脚本未分配给当前主机。',
|
||||
'hostDetails.automation.groupScripts': '分组继承脚本',
|
||||
'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。',
|
||||
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
|
||||
@@ -742,8 +742,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.targets.title': '执行目标',
|
||||
'snippets.targets.add': '添加目标',
|
||||
'snippets.targets.selectHosts': '选择主机',
|
||||
'snippets.targets.selectGroups': '选择分组',
|
||||
'snippets.targets.noGroups': '未找到分组',
|
||||
'snippets.targets.allHosts': '对所有主机生效',
|
||||
'snippets.targets.allHostsShort': '所有主机',
|
||||
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '輸入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
|
||||
'ai.providers.defaultModel': '預設模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '調整編輯器高度',
|
||||
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
|
||||
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
|
||||
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
|
||||
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
|
||||
'hostDetails.automation.groupScripts': '分組繼承指令碼',
|
||||
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
|
||||
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
|
||||
'scripts.actions.runParallel': '在所有標籤頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有標籤頁依序執行',
|
||||
|
||||
@@ -739,8 +739,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '彈出視窗編輯',
|
||||
'snippets.scriptEditor.resize': '調整編輯器高度',
|
||||
'snippets.scriptEditor.modalTitle': '編輯指令碼',
|
||||
'snippets.targets.title': '目標主機',
|
||||
'snippets.targets.add': '新增目標主機',
|
||||
'snippets.targets.title': '執行目標',
|
||||
'snippets.targets.add': '新增目標',
|
||||
'snippets.targets.selectHosts': '選擇主機',
|
||||
'snippets.targets.selectGroups': '選擇分組',
|
||||
'snippets.targets.noGroups': '未找到分組',
|
||||
'snippets.history.title': 'Shell 歷史',
|
||||
'snippets.history.subtitle': '{count} 條指令',
|
||||
'snippets.history.emptyTitle': '暫無 Shell 歷史',
|
||||
|
||||
@@ -568,6 +568,7 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
|
||||
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
|
||||
if (
|
||||
!wantDedicated
|
||||
&& options?.sourceSessionId
|
||||
&& !host.sftpSudo
|
||||
&& bridge.openSftpForSession
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
const host = {
|
||||
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
|
||||
|
||||
const noopReleaseConnection = async () => {};
|
||||
|
||||
test("SFTP session probe accepts a virtual root directory", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const ok = await probeSftpSession({
|
||||
realpathSftp: async (sftpId, remotePath) => {
|
||||
calls.push([sftpId, remotePath]);
|
||||
return "/";
|
||||
},
|
||||
}, "sftp-jumpserver");
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
|
||||
});
|
||||
|
||||
test("SFTP session probe does not require home discovery", async () => {
|
||||
const ok = await probeSftpSession(undefined, "sftp-live");
|
||||
assert.equal(ok, true);
|
||||
});
|
||||
|
||||
test("returns an existing mapped SFTP session without reconnecting", async () => {
|
||||
let connectCalls = 0;
|
||||
const sftpId = await ensureRemoteSftpSession({
|
||||
|
||||
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
|
||||
import { isSessionError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
export interface SftpSessionProbeBridge {
|
||||
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the SFTP protocol itself instead of using remote home discovery.
|
||||
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
|
||||
* root while the SFTP session remains fully usable.
|
||||
*/
|
||||
export async function probeSftpSession(
|
||||
bridge: SftpSessionProbeBridge | null | undefined,
|
||||
sftpId: string,
|
||||
): Promise<boolean> {
|
||||
if (!bridge?.realpathSftp) return true;
|
||||
await bridge.realpathSftp(sftpId, ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface EnsureRemoteSftpSessionParams {
|
||||
side: "left" | "right";
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
buildSftpConnectInFlightKey,
|
||||
buildSftpHomeDirCandidates,
|
||||
runSftpConnectOnceByKey,
|
||||
createSftpConnectionId,
|
||||
createPinnedReconnectSideResolver,
|
||||
openSftpConnectionOnce,
|
||||
@@ -15,12 +19,62 @@ import {
|
||||
resolvePinnedReconnectSide,
|
||||
} from "./useSftpConnections.ts";
|
||||
|
||||
test("home dir candidates prefer user home then root", () => {
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
|
||||
});
|
||||
|
||||
test("connection ids stay unique even when connects start in the same millisecond", () => {
|
||||
const ids = ["uuid-a", "uuid-b"];
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
|
||||
});
|
||||
|
||||
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
let runs = 0;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
});
|
||||
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
|
||||
assert.equal(runs, 1);
|
||||
releaseFirst?.();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(runs, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
|
||||
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
|
||||
const first = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-a",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
const second = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-b",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
const openOptions = {
|
||||
sessionId: "sftp-request-1",
|
||||
hostname: "192.168.9.138",
|
||||
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
|
||||
const calls: string[] = [];
|
||||
let passedOptions: NetcattySSHOptions | undefined;
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
bridge: {
|
||||
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
|
||||
calls.push(`openForSession:${sessionId}`);
|
||||
passedOptions = options;
|
||||
return "sudo-session-backed-sftp";
|
||||
},
|
||||
openSftp: async () => {
|
||||
calls.push("openSftp");
|
||||
return "fresh-sftp";
|
||||
},
|
||||
},
|
||||
sourceSessionId: "ssh-session-1",
|
||||
openOptions: {
|
||||
...openOptions,
|
||||
sudo: true,
|
||||
password: "sudo-pass",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpId, "sudo-session-backed-sftp");
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
|
||||
assert.equal(passedOptions?.sudo, true);
|
||||
assert.equal(passedOptions?.password, "sudo-pass");
|
||||
});
|
||||
|
||||
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
|
||||
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
|
||||
const calls: string[] = [];
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
|
||||
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
|
||||
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
|
||||
if (username === "root") return ["/root"];
|
||||
if (username) return [`/home/${username}`, "/root"];
|
||||
return ["/root"];
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
side: "left" | "right",
|
||||
randomUUID: () => string = () => crypto.randomUUID(),
|
||||
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
|
||||
};
|
||||
}
|
||||
|
||||
export function runSftpConnectOnceByKey(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = run().finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function buildSftpConnectInFlightKey(params: {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
targetConnectionKey: string;
|
||||
sourceSessionId?: string;
|
||||
initialPath?: string;
|
||||
forceNewTab?: boolean;
|
||||
}): string {
|
||||
return [
|
||||
params.side,
|
||||
params.tabId,
|
||||
params.targetConnectionKey,
|
||||
params.sourceSessionId ?? "",
|
||||
params.initialPath ?? "",
|
||||
params.forceNewTab ? "force-new-tab" : "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
options?.onTabCreated?.(activeTabId);
|
||||
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
const connectInFlightKey = buildSftpConnectInFlightKey({
|
||||
side,
|
||||
tabId: activeTabId,
|
||||
targetConnectionKey,
|
||||
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
|
||||
initialPath: effectiveInitialPath,
|
||||
forceNewTab: options?.forceNewTab,
|
||||
});
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
|
||||
sharedHostCache?.path,
|
||||
);
|
||||
|
||||
// Sudo never reuses a terminal shell; normalize before UI state so
|
||||
// reusedConnection (spinner/overlay) matches the actual open path.
|
||||
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
|
||||
const sourceSessionId = options?.sourceSessionId;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
} else if (credentials.username) {
|
||||
candidates.push(`/home/${credentials.username}`);
|
||||
candidates.push("/root");
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
const candidates = buildSftpHomeDirCandidates(credentials.username);
|
||||
const statSftp = bridge?.statSftp;
|
||||
if (statSftp) {
|
||||
for (const candidate of candidates) {
|
||||
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
|
||||
// root also failed
|
||||
}
|
||||
}
|
||||
// Last resort: home candidates. Covers provisional "/" from realpath
|
||||
// when discovery treated root as home and listing it failed, so
|
||||
// /home/<user> or /root can still recover the session.
|
||||
if (!fallbackSucceeded) {
|
||||
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
|
||||
if (candidate === startPath) continue;
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
fallbackSucceeded = true;
|
||||
break;
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fallbackSucceeded) {
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
|
||||
|
||||
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
|
||||
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
const handlerSource = readFileSync(
|
||||
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
|
||||
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
|
||||
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
|
||||
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.doesNotMatch(
|
||||
hookSource,
|
||||
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
|
||||
);
|
||||
});
|
||||
|
||||
test('live session sync has a complete renderer to main-process IPC path', () => {
|
||||
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
|
||||
});
|
||||
|
||||
test('latest payload sync converges to A-B-A while B is in flight', async () => {
|
||||
let releaseB: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'B') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sync.push('A');
|
||||
const sendingB = sync.push('B');
|
||||
void sync.push('A');
|
||||
releaseB?.();
|
||||
await sendingB;
|
||||
|
||||
assert.deepEqual(sent, ['A', 'B', 'A']);
|
||||
});
|
||||
|
||||
test('cancelling a payload sync drops a queued update', async () => {
|
||||
let releaseA: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'A') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const sendingA = sync.push('A');
|
||||
void sync.push('B');
|
||||
sync.cancel();
|
||||
releaseA?.();
|
||||
await sendingA;
|
||||
|
||||
assert.deepEqual(sent, ['A']);
|
||||
});
|
||||
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
};
|
||||
|
||||
export function createLatestPayloadSync<T>(
|
||||
send: (payload: T) => Promise<unknown>,
|
||||
) {
|
||||
let desired: { serialized: string; payload: T } | null = null;
|
||||
let acknowledged = '';
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const pump = async () => {
|
||||
while (!cancelled && desired && desired.serialized !== acknowledged) {
|
||||
const target = desired;
|
||||
try {
|
||||
const result = await send(target.payload);
|
||||
if (cancelled) break;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
if (desired === target) break;
|
||||
continue;
|
||||
}
|
||||
acknowledged = target.serialized;
|
||||
} catch {
|
||||
if (desired === target) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
|
||||
if (cancelled) return Promise.resolve();
|
||||
desired = { payload, serialized };
|
||||
if (!inFlight) {
|
||||
inFlight = pump().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
desired = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isMainAppWindow(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash || '';
|
||||
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
|
||||
void enabledTick;
|
||||
return readExternalMcpStoredEnabled();
|
||||
}, [enabledTick]);
|
||||
const syncGenerationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setEnabledTick((value) => value + 1);
|
||||
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
|
||||
});
|
||||
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
|
||||
|
||||
const lastSentSerializedRef = useRef('');
|
||||
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateLiveSessions) return;
|
||||
const serialized = JSON.stringify(payload);
|
||||
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
|
||||
));
|
||||
void liveSyncRef.current.push(payload, serialized);
|
||||
}, [payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
if (!enabled) {
|
||||
syncGenerationRef.current += 1;
|
||||
lastSentSerializedRef.current = '';
|
||||
// Recreate the synchronizer after re-enabling because disabling can
|
||||
// clear the main-process scope even when the renderer payload is equal.
|
||||
externalSyncRef.current?.cancel();
|
||||
externalSyncRef.current = null;
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === lastSentSerializedRef.current) return;
|
||||
|
||||
const generation = ++syncGenerationRef.current;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
|
||||
.then((result) => {
|
||||
if (generation !== syncGenerationRef.current) return;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
return;
|
||||
}
|
||||
lastSentSerializedRef.current = serialized;
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the ref unset so the next effect can retry.
|
||||
});
|
||||
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateSessions?.(
|
||||
nextPayload,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
))
|
||||
));
|
||||
void externalSyncRef.current.push(payload, serialized);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import {
|
||||
createDeferredOutputTriggerEventProcessor,
|
||||
createOutputTriggerScanBuffer,
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
findMatchEndingAfter,
|
||||
hasApplicableOutputTriggerSnippet,
|
||||
inspectDroppedOutputOverflowAlternateScreenState,
|
||||
useOutputTriggers,
|
||||
} from './useOutputTriggers.ts';
|
||||
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
@@ -93,6 +96,80 @@ test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for t
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet follows the host current group', () => {
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'group-trigger',
|
||||
label: 'Group trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Production/Web' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Staging' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('equivalent host objects do not reset a pending output trigger match', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'stable-host-trigger',
|
||||
label: 'Stable host trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
let appendOutput: ((chunk: string) => void) | undefined;
|
||||
let runCount = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onRunScript = () => {
|
||||
runCount += 1;
|
||||
};
|
||||
|
||||
const Probe = ({ host }: { host: { id: string; group: string } }) => {
|
||||
appendOutput = useOutputTriggers({
|
||||
sessionId: 'session-1',
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}).appendOutput;
|
||||
return null;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
appendOutput?.('REA');
|
||||
renderer?.update(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
appendOutput?.('DY');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.equal(runCount, 1);
|
||||
});
|
||||
|
||||
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
|
||||
const buffer = createOutputTriggerScanBuffer(4);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
|
||||
import { isScriptSnippet } from '@/domain/snippetScript.ts';
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048;
|
||||
|
||||
type OutputTriggerContext = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
host?: Pick<Host, 'id' | 'group'>;
|
||||
snippets: Snippet[];
|
||||
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
|
||||
};
|
||||
@@ -575,22 +575,31 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean {
|
||||
export function hasApplicableOutputTriggerSnippet(
|
||||
snippets: Snippet[],
|
||||
host?: Pick<Host, 'id' | 'group'> | string,
|
||||
): boolean {
|
||||
return snippets.some((snippet) => (
|
||||
isScriptSnippet(snippet)
|
||||
&& snippet.trigger === 'onOutput'
|
||||
&& Boolean(snippet.triggerPattern)
|
||||
&& Boolean(snippet.id)
|
||||
&& snippetAppliesToOutputTrigger(snippet, hostId)
|
||||
&& snippetAppliesToOutputTrigger(snippet, host)
|
||||
));
|
||||
}
|
||||
|
||||
export function useOutputTriggers({
|
||||
sessionId,
|
||||
hostId,
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}: OutputTriggerContext) {
|
||||
const targetHostId = host?.id;
|
||||
const targetHostGroup = host?.group;
|
||||
const targetHost = useMemo(
|
||||
() => targetHostId ? { id: targetHostId, group: targetHostGroup } : undefined,
|
||||
[targetHostGroup, targetHostId],
|
||||
);
|
||||
const launchingRef = useRef(false);
|
||||
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
|
||||
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
|
||||
@@ -600,8 +609,8 @@ export function useOutputTriggers({
|
||||
const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null);
|
||||
const pendingDroppedOverflowScanStateResetRef = useRef(false);
|
||||
const hasOutputTriggers = useMemo(
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, hostId),
|
||||
[hostId, snippets],
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, targetHost),
|
||||
[snippets, targetHost],
|
||||
);
|
||||
|
||||
const scanOutput = useCallback((scannableText: string) => {
|
||||
@@ -621,7 +630,7 @@ export function useOutputTriggers({
|
||||
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) {
|
||||
continue;
|
||||
}
|
||||
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
|
||||
if (!snippetAppliesToOutputTrigger(snippet, targetHost)) continue;
|
||||
try {
|
||||
const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset);
|
||||
if (!matched) {
|
||||
@@ -647,7 +656,7 @@ export function useOutputTriggers({
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
}, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]);
|
||||
}, [hasOutputTriggers, onRunScript, sessionId, snippets, targetHost]);
|
||||
|
||||
const scanOutputRef = useRef(scanOutput);
|
||||
scanOutputRef.current = scanOutput;
|
||||
@@ -813,7 +822,7 @@ export function useOutputTriggers({
|
||||
outputTriggerScanSuppressedRef.current = false;
|
||||
pendingDroppedOverflowFinalActionRef.current = null;
|
||||
pendingDroppedOverflowScanStateResetRef.current = false;
|
||||
}, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
}, [sessionId, targetHost, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
|
||||
useEffect(() => () => {
|
||||
outputTriggerEventProcessor.reset();
|
||||
|
||||
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
|
||||
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
|
||||
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
|
||||
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
|
||||
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
|
||||
import {
|
||||
createTransferPoolKeyCache,
|
||||
@@ -316,9 +316,7 @@ export const useSftpState = (
|
||||
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
|
||||
// not repeated. Transport leases keep the shared conn alive after the
|
||||
// terminal tab closes until the transfer SFTP lease is returned.
|
||||
const sourceSessionId = !host.sftpSudo
|
||||
? resolveTransferSourceSessionId?.(host.id, host)
|
||||
: undefined;
|
||||
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
|
||||
if (sourceSessionId) {
|
||||
try {
|
||||
logger.info(
|
||||
@@ -608,16 +606,7 @@ export const useSftpState = (
|
||||
forceReconnect: ensureOptions?.forceReconnect,
|
||||
releaseConnection,
|
||||
tabId,
|
||||
probeSession: async (sftpId) => {
|
||||
// Lightweight liveness check; any session-error from the bridge
|
||||
// triggers a reconnect in ensureRemoteSftpSession.
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
|
||||
});
|
||||
},
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
@@ -704,6 +693,12 @@ export const useSftpState = (
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) continue;
|
||||
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
|
||||
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
|
||||
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
|
||||
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
|
||||
const resolvedSourceSessionId = targetHost
|
||||
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
|
||||
: undefined;
|
||||
try {
|
||||
await ensureRemoteSftpSession({
|
||||
side,
|
||||
@@ -713,16 +708,10 @@ export const useSftpState = (
|
||||
connect,
|
||||
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
|
||||
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
|
||||
resolveSourceSessionId: resolveBrowseSourceSessionId,
|
||||
probeSession: async (sftpId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
resolveSourceSessionId: resolvedSourceSessionId
|
||||
? () => resolvedSourceSessionId
|
||||
: resolveBrowseSourceSessionId,
|
||||
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
|
||||
releaseConnection,
|
||||
});
|
||||
logger.info(`[SFTP] Restored browse session on ${side}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resolveVaultAgentEffectiveHost,
|
||||
resolveVaultAgentNotes,
|
||||
} from './useVaultAgentBridge';
|
||||
import type { Host } from '../../domain/models';
|
||||
import { publishNotesSnapshot } from './notesStore';
|
||||
|
||||
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
|
||||
@@ -28,10 +29,10 @@ describe('haveSameVaultAgentSnapshot', () => {
|
||||
|
||||
describe('resolveVaultAgentEffectiveHost', () => {
|
||||
it('uses the latest snapshotted group defaults', () => {
|
||||
const host = {
|
||||
const host: Host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'production', tags: [], os: 'linux',
|
||||
} as const;
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveVaultAgentEffectiveHost(host, [{ path: 'production', protocol: 'telnet' }], []).protocol,
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
resolveDefaultKeyPassphraseAliases,
|
||||
} from '../defaultKeyPassphrases';
|
||||
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from '../../domain/vaultGroupMutation';
|
||||
|
||||
export interface UseVaultAgentBridgeInput {
|
||||
hosts: Host[];
|
||||
@@ -29,12 +33,17 @@ export interface UseVaultAgentBridgeInput {
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
updateSnippets: (snippets: Snippet[]) => void;
|
||||
updateSnippets: (
|
||||
snippets: Snippet[] | ((current: Snippet[]) => Snippet[]),
|
||||
) => void;
|
||||
customGroups: string[];
|
||||
updateCustomGroups: (groups: string[]) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
updateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
updateManagedSources: (sources: ManagedSource[]) => void;
|
||||
commitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
|
||||
notes?: VaultNote[];
|
||||
@@ -185,6 +194,17 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.hosts = hosts;
|
||||
current.updateHosts(hosts);
|
||||
},
|
||||
commitVaultGroupMutation: async (mutate) => {
|
||||
const result = await current.commitVaultGroupMutation(mutate);
|
||||
if (result.ok) {
|
||||
vaultSnapshotRef.current.customGroups = result.state.groups;
|
||||
vaultSnapshotRef.current.groupConfigs = result.state.configs;
|
||||
vaultSnapshotRef.current.hosts = result.state.hosts;
|
||||
vaultSnapshotRef.current.managedSources = result.state.managedSources;
|
||||
vaultSnapshotRef.current.snippets = result.state.snippets;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
saveKeyPassphrase: (keyPath, passphrase) => rememberKeyPassphrase({
|
||||
keyPath,
|
||||
passphrase,
|
||||
@@ -222,9 +242,11 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.notes = notes;
|
||||
applyUpdateNotes(notes);
|
||||
},
|
||||
updateSnippets: (nextSnippets) => {
|
||||
vaultSnapshotRef.current.snippets = nextSnippets;
|
||||
current.updateSnippets(nextSnippets);
|
||||
updateSnippets: (snippetUpdate) => {
|
||||
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
|
||||
? snippetUpdate(vaultSnapshotRef.current.snippets)
|
||||
: snippetUpdate;
|
||||
current.updateSnippets(snippetUpdate);
|
||||
},
|
||||
startTunnel: current.startTunnel,
|
||||
stopTunnel: current.stopTunnel,
|
||||
|
||||
@@ -31,19 +31,16 @@ test("group deletion removes saved settings in the same Vault transaction", asyn
|
||||
managedSources: storedSources,
|
||||
onReadPersistedHosts: async () => [],
|
||||
onReadPersistedManagedSources: () => storedSources,
|
||||
onCommitVaultImportTransaction: async (
|
||||
_hosts,
|
||||
updateGroups,
|
||||
updateSources,
|
||||
updateGroupConfigs,
|
||||
) => {
|
||||
committedGroupConfigs = updateGroupConfigs?.(storedGroupConfigs) ?? storedGroupConfigs;
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: updateGroups(storedGroups),
|
||||
sources: updateSources(storedSources),
|
||||
groupConfigs: committedGroupConfigs,
|
||||
};
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
const result = mutate({
|
||||
groups: storedGroups,
|
||||
configs: storedGroupConfigs,
|
||||
hosts: [],
|
||||
managedSources: storedSources,
|
||||
snippets: [],
|
||||
});
|
||||
if (result.ok) committedGroupConfigs = result.state.configs;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -102,14 +99,9 @@ test("group deletion restores managed files when the persisted Vault cannot be r
|
||||
onClearAndRemoveManagedSources: async () => async () => {
|
||||
restoreCount += 1;
|
||||
},
|
||||
onCommitVaultImportTransaction: async () => {
|
||||
onCommitVaultGroupMutation: async () => {
|
||||
commitCount += 1;
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: [],
|
||||
sources: [],
|
||||
groupConfigs: [],
|
||||
};
|
||||
throw new Error("unexpected commit");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -187,21 +179,15 @@ test("group deletion retries file clearing when another managed source enters th
|
||||
restoreCount += 1;
|
||||
};
|
||||
},
|
||||
onCommitVaultImportTransaction: async (
|
||||
_hosts,
|
||||
updateGroups,
|
||||
updateSources,
|
||||
updateGroupConfigs,
|
||||
expectedHosts,
|
||||
) => {
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
commitCount += 1;
|
||||
assert.deepEqual(expectedHosts, []);
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: updateGroups(["Production"]),
|
||||
sources: updateSources([firstSource, concurrentSource]),
|
||||
groupConfigs: updateGroupConfigs?.([]) ?? [],
|
||||
};
|
||||
return mutate({
|
||||
groups: ["Production"],
|
||||
configs: [],
|
||||
hosts: [],
|
||||
managedSources: [firstSource, concurrentSource],
|
||||
snippets: [],
|
||||
});
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -228,3 +214,67 @@ test("group deletion retries file clearing when another managed source enters th
|
||||
assert.equal(restoreCount, 1);
|
||||
assert.equal(commitCount, 1);
|
||||
});
|
||||
|
||||
test("group deletion restores managed files and does not report success when commit fails", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const source: ManagedSource = {
|
||||
id: "managed-production",
|
||||
type: "ssh_config",
|
||||
filePath: "/tmp/managed-production.conf",
|
||||
groupName: "Production",
|
||||
lastSyncedAt: 1,
|
||||
};
|
||||
let deleteGroups: ((paths: Iterable<string>) => Promise<void>) | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let restoreCount = 0;
|
||||
let reportedCount = 0;
|
||||
|
||||
const Probe = () => {
|
||||
deleteGroups = useVaultGroupDeletion({
|
||||
customGroups: ["Production"],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [source],
|
||||
onReadPersistedHosts: async () => [],
|
||||
onReadPersistedManagedSources: () => [source],
|
||||
onClearAndRemoveManagedSources: async () => async () => {
|
||||
restoreCount += 1;
|
||||
},
|
||||
onCommitVaultGroupMutation: async () => {
|
||||
throw new Error("Vault quota exhausted");
|
||||
},
|
||||
onDeletedPaths: () => {
|
||||
reportedCount += 1;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
let deletionError: unknown;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
await deleteGroups?.(["Production"]);
|
||||
});
|
||||
} catch (error) {
|
||||
deletionError = error;
|
||||
}
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.match(String(deletionError), /quota exhausted/i);
|
||||
assert.equal(restoreCount, 1);
|
||||
assert.equal(reportedCount, 0);
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
|
||||
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
|
||||
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../../domain/vaultGroupMutation";
|
||||
import {
|
||||
type VaultLockHandle,
|
||||
withVaultImportLock,
|
||||
@@ -26,7 +31,7 @@ export function useVaultGroupDeletion({
|
||||
managedSources,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onDeletedPaths,
|
||||
@@ -37,22 +42,10 @@ export function useVaultGroupDeletion({
|
||||
managedSources: ManagedSource[];
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onReadPersistedManagedSources: () => ManagedSource[];
|
||||
onCommitVaultImportTransaction: (
|
||||
hosts: Host[],
|
||||
updateGroups: (current: string[]) => string[],
|
||||
updateSources: (current: ManagedSource[]) => ManagedSource[],
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
onCommitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
) => Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
groups: string[];
|
||||
sources: ManagedSource[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
| { status: "superseded" }
|
||||
>;
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
|
||||
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
|
||||
onDeletedPaths?: (selectedRoots: string[]) => void;
|
||||
@@ -120,29 +113,16 @@ export function useVaultGroupDeletion({
|
||||
}
|
||||
deletion = refreshedDeletion;
|
||||
|
||||
const expectedHosts = latestRef.current.hosts;
|
||||
const expectedSources = deletion.sourcesToRemove;
|
||||
const nextHosts = deletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
);
|
||||
const transaction = await onCommitVaultImportTransaction(
|
||||
nextHosts,
|
||||
(currentGroups) => buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: currentGroups,
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [],
|
||||
}).customGroups,
|
||||
(currentSources) => {
|
||||
const transaction = await onCommitVaultGroupMutation(
|
||||
(current) => {
|
||||
const currentDeletion = buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: [],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: currentSources,
|
||||
customGroups: current.groups,
|
||||
hosts: current.hosts,
|
||||
groupConfigs: current.configs,
|
||||
managedSources: current.managedSources,
|
||||
});
|
||||
if (!managedSourceSnapshotsMatch(
|
||||
expectedSources,
|
||||
@@ -150,29 +130,40 @@ export function useVaultGroupDeletion({
|
||||
)) {
|
||||
throw RETRY_VAULT_GROUP_DELETION;
|
||||
}
|
||||
const removedIds = new Set(expectedSources.map((source) => source.id));
|
||||
return currentSources.filter((source) => !removedIds.has(source.id));
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: currentDeletion.customGroups,
|
||||
hosts: currentDeletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
),
|
||||
configs: currentDeletion.groupConfigs,
|
||||
managedSources: current.managedSources.filter(
|
||||
(source) => !currentDeletion.sourcesToRemove.some(
|
||||
(removed) => removed.id === source.id,
|
||||
),
|
||||
),
|
||||
snippets: removeSnippetTargetGroupPaths(
|
||||
current.snippets,
|
||||
currentDeletion.selectedRoots,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
(currentGroupConfigs) => buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: [],
|
||||
hosts: [],
|
||||
groupConfigs: currentGroupConfigs,
|
||||
managedSources: [],
|
||||
}).groupConfigs,
|
||||
expectedHosts,
|
||||
lock,
|
||||
);
|
||||
if (transaction.status === "superseded") {
|
||||
// Release the lock so concurrent host saves can finish before retry.
|
||||
if (!transaction.ok && "superseded" in transaction) {
|
||||
// Release the lock so concurrent saves can finish before retry.
|
||||
return { status: "retry" as const, restoreManagedFiles };
|
||||
}
|
||||
if ("error" in transaction) {
|
||||
throw new Error(transaction.error);
|
||||
}
|
||||
latestRef.current = {
|
||||
customGroups: transaction.groups,
|
||||
hosts: nextHosts,
|
||||
groupConfigs: transaction.groupConfigs,
|
||||
managedSources: transaction.sources,
|
||||
customGroups: transaction.state.groups,
|
||||
hosts: transaction.state.hosts,
|
||||
groupConfigs: transaction.state.configs,
|
||||
managedSources: transaction.state.managedSources,
|
||||
};
|
||||
return {
|
||||
status: "done" as const,
|
||||
@@ -205,7 +196,7 @@ export function useVaultGroupDeletion({
|
||||
}, [
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onDeletedPaths,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
|
||||
@@ -66,7 +66,7 @@ test("updateSnippets disk writes take the shared vault lock", () => {
|
||||
// and be visible to waitForPendingVaultWrites.
|
||||
assert.match(
|
||||
source,
|
||||
/const updateSnippets = useCallback\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
/const updateSnippets = useCallback\(\([\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -95,6 +95,24 @@ test("updateSnippets rebases onto the latest persisted snapshot under the lock",
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets functional updates derive from the latest in-memory snapshot", () => {
|
||||
// Group deletion can wait for managed-source cleanup. A script created while
|
||||
// it waits must still be present when the deletion callback removes the old
|
||||
// group path, so updater callbacks must not derive from the initiating render.
|
||||
assert.match(
|
||||
source,
|
||||
/data: Snippet\[\] \| \(\(current: Snippet\[\]\) => Snippet\[\]\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const updater = typeof data === "function" \? data : null;[\s\S]*const current = snippetsRef\.current;[\s\S]*typeof data === "function" \? data\(current\) : data/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/updater\(rebaseSnippetVaultWrite\(\{\s*base,\s*ours: current,\s*theirs: latestPersisted,\s*\}\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("clearVaultData replaces snippets without additive rebase", () => {
|
||||
// Clear All Local Data must not preserve concurrent disk-only snippet adds
|
||||
// that rebaseSnippetVaultWrite would otherwise keep.
|
||||
@@ -108,7 +126,7 @@ test("clearVaultData replaces snippets without additive rebase", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
|
||||
/replace\s*\?\s*cleaned\s*:\s*updater\s*\?\s*updater\(rebaseSnippetVaultWrite/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -145,7 +163,7 @@ test("updateSnippets keeps the persisted rebase ancestor across superseded saves
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
|
||||
/const current = snippetsRef\.current;\s*const base = snippetsWriteBaseRef\.current \?\? current/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
|
||||
@@ -106,10 +106,15 @@ import {
|
||||
persistVaultImportMetadata,
|
||||
readStoredArray,
|
||||
} from "./vaultImportPersistence";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../../domain/vaultGroupMutation";
|
||||
import {
|
||||
commitPluginImporterTransaction,
|
||||
recoverPluginImporterTransaction,
|
||||
} from "./pluginImporterTransaction";
|
||||
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
@@ -296,6 +301,7 @@ export const useVaultState = () => {
|
||||
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
const snippetsRef = useRef<Snippet[]>([]);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
const notesPersistFailureNotifiedAtRef = useRef(0);
|
||||
@@ -303,6 +309,7 @@ export const useVaultState = () => {
|
||||
managedSourcesRef.current = managedSources;
|
||||
hostsRef.current = hosts;
|
||||
snippetsRef.current = snippets;
|
||||
groupConfigsRef.current = groupConfigs;
|
||||
notesRef.current = notes;
|
||||
noteGroupsRef.current = noteGroups;
|
||||
|
||||
@@ -314,6 +321,7 @@ export const useVaultState = () => {
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const proxyProfilesWriteVersion = useRef(0);
|
||||
const snippetsWriteVersion = useRef(0);
|
||||
const managedSourcesWriteVersion = useRef(0);
|
||||
// Tracks the latest local updateSnippets schedule. Storage events also bump
|
||||
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
|
||||
// key supersede checks off this owner instead of that shared counter.
|
||||
@@ -340,6 +348,7 @@ export const useVaultState = () => {
|
||||
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const managedSourcesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
const waitForPendingVaultWrites = useCallback(async () => {
|
||||
while (true) {
|
||||
@@ -365,6 +374,7 @@ export const useVaultState = () => {
|
||||
identitiesWritePendingRef.current,
|
||||
groupConfigsWritePendingRef.current,
|
||||
snippetsWritePendingRef.current,
|
||||
managedSourcesWritePendingRef.current,
|
||||
];
|
||||
await Promise.all(writePending);
|
||||
if (
|
||||
@@ -373,6 +383,7 @@ export const useVaultState = () => {
|
||||
&& writePending[2] === identitiesWritePendingRef.current
|
||||
&& writePending[3] === groupConfigsWritePendingRef.current
|
||||
&& writePending[4] === snippetsWritePendingRef.current
|
||||
&& writePending[5] === managedSourcesWritePendingRef.current
|
||||
&& encryptPending[0] === hostsEncryptPendingRef.current
|
||||
&& encryptPending[1] === keysEncryptPendingRef.current
|
||||
&& encryptPending[2] === identitiesEncryptPendingRef.current
|
||||
@@ -643,7 +654,7 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((
|
||||
data: Snippet[],
|
||||
data: Snippet[] | ((current: Snippet[]) => Snippet[]),
|
||||
options?: { replace?: boolean },
|
||||
) => {
|
||||
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
|
||||
@@ -653,7 +664,9 @@ export const useVaultState = () => {
|
||||
// second edit does not rebase against the first save's optimistic memory.
|
||||
const replace =
|
||||
options?.replace === true || snippetsWriteReplaceRef.current;
|
||||
const base = snippetsWriteBaseRef.current ?? snippetsRef.current;
|
||||
const updater = typeof data === "function" ? data : null;
|
||||
const current = snippetsRef.current;
|
||||
const base = snippetsWriteBaseRef.current ?? current;
|
||||
if (options?.replace === true) {
|
||||
snippetsWriteReplaceRef.current = true;
|
||||
}
|
||||
@@ -667,7 +680,9 @@ export const useVaultState = () => {
|
||||
} else if (snippetsWriteBaseRef.current === null) {
|
||||
snippetsWriteBaseRef.current = base;
|
||||
}
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
const cleaned = normalizeVaultOrder(
|
||||
typeof data === "function" ? data(current) : data,
|
||||
);
|
||||
const ver = ++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = ver;
|
||||
// Keep live snapshot ahead of React commit so same-tick readers (delete,
|
||||
@@ -681,20 +696,25 @@ export const useVaultState = () => {
|
||||
// skips the additive rebase so a concurrent disk-only add cannot survive.
|
||||
const writePromise = withVaultImportLock("vault", async () => {
|
||||
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
|
||||
const rebased = normalizeVaultOrder(
|
||||
replace
|
||||
? cleaned
|
||||
const latestPersisted = normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
);
|
||||
const rebased = normalizeVaultOrder(replace
|
||||
? cleaned
|
||||
: updater
|
||||
? updater(rebaseSnippetVaultWrite({
|
||||
base,
|
||||
ours: current,
|
||||
theirs: latestPersisted,
|
||||
}))
|
||||
: rebaseSnippetVaultWrite({
|
||||
base,
|
||||
ours: cleaned,
|
||||
theirs: normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
theirs: latestPersisted,
|
||||
}));
|
||||
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
|
||||
if (!persisted) {
|
||||
// Keep the pre-update ancestor. Clearing it here would make the next
|
||||
@@ -852,6 +872,7 @@ export const useVaultState = () => {
|
||||
setCustomGroups(next);
|
||||
|
||||
const cleanedGroupConfigs = buildGroupConfigsForGroups(next, groupConfigs);
|
||||
groupConfigsRef.current = cleanedGroupConfigs;
|
||||
setGroupConfigs(cleanedGroupConfigs);
|
||||
const configsVer = ++groupConfigsWriteVersion.current;
|
||||
const encryptPromise = encryptGroupConfigs(cleanedGroupConfigs);
|
||||
@@ -880,13 +901,105 @@ export const useVaultState = () => {
|
||||
const next = typeof data === "function" ? data(managedSourcesRef.current) : data;
|
||||
managedSourcesRef.current = next;
|
||||
setManagedSources(next);
|
||||
void withVaultImportLock("vault", async () => {
|
||||
const ver = ++managedSourcesWriteVersion.current;
|
||||
const writePromise = withVaultImportLock("vault", async () => {
|
||||
// Latest ref wins if another update ran while waiting for the lock.
|
||||
if (managedSourcesRef.current !== next) return;
|
||||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next);
|
||||
if (ver !== managedSourcesWriteVersion.current) return "superseded" as const;
|
||||
return localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next)
|
||||
? "written" as const
|
||||
: "failed" as const;
|
||||
});
|
||||
managedSourcesWritePendingRef.current = writePromise;
|
||||
return writePromise;
|
||||
}, []);
|
||||
|
||||
const commitVaultGroupMutation = useCallback(async (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
|
||||
const captureVersions = () => ({
|
||||
hosts: hostsWriteVersion.current,
|
||||
snippets: snippetsWriteVersion.current,
|
||||
groups: customGroupsWriteVersion.current,
|
||||
configs: groupConfigsWriteVersion.current,
|
||||
sources: managedSourcesWriteVersion.current,
|
||||
});
|
||||
const versionsAreCurrent = (versions: ReturnType<typeof captureVersions>) => (
|
||||
versions.hosts === hostsWriteVersion.current
|
||||
&& versions.snippets === snippetsWriteVersion.current
|
||||
&& versions.groups === customGroupsWriteVersion.current
|
||||
&& versions.configs === groupConfigsWriteVersion.current
|
||||
&& versions.sources === managedSourcesWriteVersion.current
|
||||
);
|
||||
const stateMatchesLiveSnapshot = (current: VaultGroupMutationState) => (
|
||||
JSON.stringify(current.hosts) === JSON.stringify(hostsRef.current)
|
||||
&& JSON.stringify(current.snippets) === JSON.stringify(snippetsRef.current)
|
||||
&& JSON.stringify(current.groups) === JSON.stringify(customGroupsRef.current)
|
||||
&& JSON.stringify(current.configs) === JSON.stringify(groupConfigsRef.current)
|
||||
&& JSON.stringify(current.managedSources) === JSON.stringify(managedSourcesRef.current)
|
||||
);
|
||||
const runCommit = async (
|
||||
versions: ReturnType<typeof captureVersions>,
|
||||
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage: localStorageAdapter,
|
||||
mutate,
|
||||
prepareState: (state) => {
|
||||
const groups = Array.from(new Set(state.groups));
|
||||
return {
|
||||
groups,
|
||||
configs: buildGroupConfigsForGroups(groups, state.configs),
|
||||
hosts: normalizeVaultOrder(state.hosts.map((host) => sanitizeHost(host))),
|
||||
managedSources: state.managedSources,
|
||||
snippets: normalizeVaultOrder(state.snippets),
|
||||
};
|
||||
},
|
||||
decryptHosts: async (items) => normalizeVaultOrder(
|
||||
(await decryptHosts(items)).map((host) => sanitizeHost(host)),
|
||||
),
|
||||
decryptConfigs: async (items) => normalizeVaultOrder(
|
||||
(await decryptGroupConfigs(items)).map(sanitizeGroupConfig),
|
||||
),
|
||||
encryptHosts,
|
||||
encryptConfigs: encryptGroupConfigs,
|
||||
isCurrent: () => versionsAreCurrent(versions),
|
||||
validateCurrent: lock ? stateMatchesLiveSnapshot : undefined,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
const nextState = result.state;
|
||||
++hostsWriteVersion.current;
|
||||
++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = snippetsWriteVersion.current;
|
||||
++customGroupsWriteVersion.current;
|
||||
++groupConfigsWriteVersion.current;
|
||||
++managedSourcesWriteVersion.current;
|
||||
hostsRef.current = nextState.hosts;
|
||||
snippetsRef.current = nextState.snippets;
|
||||
customGroupsRef.current = nextState.groups;
|
||||
managedSourcesRef.current = nextState.managedSources;
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
setHosts(nextState.hosts);
|
||||
setSnippets(nextState.snippets);
|
||||
setCustomGroups(nextState.groups);
|
||||
setManagedSources(nextState.managedSources);
|
||||
setGroupConfigs(nextState.configs);
|
||||
groupConfigsRef.current = nextState.configs;
|
||||
return { ok: true, state: nextState };
|
||||
};
|
||||
|
||||
if (lock) {
|
||||
const versions = captureVersions();
|
||||
return withVaultImportLockIfNeeded("vault", () => runCommit(versions), lock);
|
||||
}
|
||||
while (true) {
|
||||
await waitForPendingVaultWrites();
|
||||
const versions = captureVersions();
|
||||
const result = await withVaultImportLock("vault", () => runCommit(versions));
|
||||
if (!("superseded" in result)) return result;
|
||||
}
|
||||
}, [waitForPendingVaultWrites]);
|
||||
|
||||
const readPersistedManagedSources = useCallback((): ManagedSource[] => (
|
||||
readStoredArray<ManagedSource>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
@@ -996,6 +1109,8 @@ export const useVaultState = () => {
|
||||
++groupConfigsWriteVersion.current;
|
||||
customGroupsRef.current = result.groups;
|
||||
managedSourcesRef.current = result.sources;
|
||||
hostsRef.current = cleanedHosts;
|
||||
groupConfigsRef.current = nextGroupConfigs;
|
||||
setHosts(cleanedHosts);
|
||||
setCustomGroups(result.groups);
|
||||
setManagedSources(result.sources);
|
||||
@@ -1018,6 +1133,7 @@ export const useVaultState = () => {
|
||||
// sit in memory and re-persist with `fontFamilyOverride: true` until
|
||||
// the next reload. Mirrors updateHosts → sanitizeHost.
|
||||
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = cleaned;
|
||||
setGroupConfigs(cleaned);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
const encryptPromise = encryptGroupConfigs(cleaned);
|
||||
@@ -1461,7 +1577,10 @@ export const useVaultState = () => {
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
if (savedManagedSources) {
|
||||
managedSourcesRef.current = savedManagedSources;
|
||||
setManagedSources(savedManagedSources);
|
||||
}
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
@@ -1471,6 +1590,7 @@ export const useVaultState = () => {
|
||||
if (cancelled) return;
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = sanitizedGC;
|
||||
setGroupConfigs(sanitizedGC);
|
||||
encryptGroupConfigs(sanitizedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
@@ -1641,6 +1761,7 @@ export const useVaultState = () => {
|
||||
if (key === STORAGE_KEY_GROUPS) {
|
||||
const next = safeParse<string[]>(event.newValue) ?? [];
|
||||
++customGroupsWriteVersion.current;
|
||||
customGroupsRef.current = next;
|
||||
setCustomGroups(next);
|
||||
return;
|
||||
}
|
||||
@@ -1695,6 +1816,8 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_MANAGED_SOURCES) {
|
||||
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
|
||||
++managedSourcesWriteVersion.current;
|
||||
managedSourcesRef.current = next;
|
||||
setManagedSources(next);
|
||||
return;
|
||||
}
|
||||
@@ -1705,8 +1828,11 @@ export const useVaultState = () => {
|
||||
const seq = ++groupConfigsReadSeq.current;
|
||||
const writeAtStart = groupConfigsWriteVersion.current;
|
||||
decryptGroupConfigs(next).then((dec) => {
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
|
||||
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current) {
|
||||
const normalized = normalizeVaultOrder(dec.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = normalized;
|
||||
setGroupConfigs(normalized);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1794,7 +1920,7 @@ export const useVaultState = () => {
|
||||
const importData = useCallback(
|
||||
(payload: Partial<ExportableVaultData>): Promise<void> => {
|
||||
const encryptedWrites: Promise<void>[] = [];
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts).then(() => undefined));
|
||||
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
|
||||
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
|
||||
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
|
||||
@@ -1952,6 +2078,8 @@ export const useVaultState = () => {
|
||||
]);
|
||||
customGroupsRef.current = nextGroups;
|
||||
snippetsRef.current = nextSnippets;
|
||||
hostsRef.current = nextHosts;
|
||||
groupConfigsRef.current = nextGroupConfigs;
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
setHosts(nextHosts);
|
||||
@@ -2008,6 +2136,7 @@ export const useVaultState = () => {
|
||||
updateManagedSources,
|
||||
readPersistedManagedSources,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateGroupConfigs,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Host, Snippet } from '../../domain/models';
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
removeSnippetTargetGroupPaths,
|
||||
} from '../../domain/hostGroupPathMutations';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
|
||||
|
||||
const createStorage = (initial: Record<string, unknown>, failKey?: string) => {
|
||||
const values = new Map(Object.entries(initial).map(([key, value]) => [key, JSON.stringify(value)]));
|
||||
return {
|
||||
values,
|
||||
read<T>(key: string): T | null {
|
||||
const value = values.get(key);
|
||||
return value === undefined ? null : JSON.parse(value) as T;
|
||||
},
|
||||
readString: (key: string) => values.get(key) ?? null,
|
||||
write<T>(key: string, value: T) {
|
||||
if (key === failKey) return false;
|
||||
values.set(key, JSON.stringify(value));
|
||||
return true;
|
||||
},
|
||||
writeString(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
return true;
|
||||
},
|
||||
remove(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const identity = async <T>(value: T): Promise<T> => value;
|
||||
const prepareState = <T>(value: T): T => value;
|
||||
|
||||
const initialHost: Host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'prod', tags: [], os: 'linux',
|
||||
};
|
||||
const initialSnippet: Snippet = {
|
||||
id: 'script-1', label: 'Deploy', command: 'nct.log(1)', kind: 'script',
|
||||
targetGroups: ['prod'],
|
||||
};
|
||||
|
||||
function initialVault() {
|
||||
return {
|
||||
[STORAGE_KEY_HOSTS]: [initialHost],
|
||||
[STORAGE_KEY_GROUPS]: ['prod'],
|
||||
[STORAGE_KEY_MANAGED_SOURCES]: [],
|
||||
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', username: 'root' }],
|
||||
[STORAGE_KEY_SNIPPETS]: [initialSnippet],
|
||||
};
|
||||
}
|
||||
|
||||
test('group rename rolls back every Vault key when the snippet write fails', async () => {
|
||||
const initial = initialVault();
|
||||
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
|
||||
|
||||
await assert.rejects(commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: ['production'],
|
||||
configs: current.configs.map((config) => ({ ...config, path: 'production' })),
|
||||
hosts: current.hosts.map((host) => ({ ...host, group: 'production' })),
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
}), /rejected importer transaction/);
|
||||
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('group delete rolls back every Vault key when the snippet write fails', async () => {
|
||||
const initial = initialVault();
|
||||
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
|
||||
|
||||
await assert.rejects(commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: [],
|
||||
configs: [],
|
||||
hosts: current.hosts.map((host) => ({ ...host, group: undefined })),
|
||||
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
}), /rejected importer transaction/);
|
||||
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('group rename preserves a concurrent snippet update from the locked snapshot', async () => {
|
||||
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
|
||||
const addedSnippet: Snippet = {
|
||||
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
|
||||
targetGroups: ['prod/child'],
|
||||
};
|
||||
const storage = createStorage({
|
||||
...initialVault(),
|
||||
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
|
||||
});
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: ['production'],
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
|
||||
assert.equal(saved[0]?.command, 'nct.log(2)');
|
||||
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
|
||||
['production'],
|
||||
['production/child'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group delete cleans targets from the latest persisted snippet snapshot', async () => {
|
||||
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
|
||||
const addedSnippet: Snippet = {
|
||||
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
|
||||
targetGroups: ['prod/child', 'staging'],
|
||||
};
|
||||
const storage = createStorage({
|
||||
...initialVault(),
|
||||
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
|
||||
});
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: [],
|
||||
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
|
||||
assert.equal(saved[0]?.command, 'nct.log(2)');
|
||||
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
|
||||
[],
|
||||
['staging'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group mutation stops before writing when memory is ahead of persisted Vault state', async () => {
|
||||
const storage = createStorage(initialVault());
|
||||
let mutateCalled = false;
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => {
|
||||
mutateCalled = true;
|
||||
return { ok: true, state: current };
|
||||
},
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
validateCurrent: (current) => current.snippets[0]?.command === 'nct.log(2)',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, superseded: true });
|
||||
assert.equal(mutateCalled, false);
|
||||
assert.deepEqual(storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS), [initialSnippet]);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { GroupConfig, Host, ManagedSource, Snippet } from '../../domain/models';
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from '../../domain/vaultGroupMutation';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { commitPluginImporterTransaction } from './pluginImporterTransaction';
|
||||
import { readStoredArray } from './vaultImportPersistence';
|
||||
|
||||
type TransactionStorage = {
|
||||
read<T>(key: string): T | null;
|
||||
readString(key: string): string | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
writeString(key: string, value: string): boolean;
|
||||
remove(key: string): void;
|
||||
};
|
||||
|
||||
const GROUP_MUTATION_KEYS = [
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
] as const;
|
||||
|
||||
export async function commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate,
|
||||
prepareState,
|
||||
decryptHosts,
|
||||
decryptConfigs,
|
||||
encryptHosts,
|
||||
encryptConfigs,
|
||||
isCurrent,
|
||||
validateCurrent,
|
||||
}: {
|
||||
storage: TransactionStorage;
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult;
|
||||
prepareState: (state: VaultGroupMutationState) => VaultGroupMutationState;
|
||||
decryptHosts: (hosts: Host[]) => Promise<Host[]>;
|
||||
decryptConfigs: (configs: GroupConfig[]) => Promise<GroupConfig[]>;
|
||||
encryptHosts: (hosts: Host[]) => Promise<unknown>;
|
||||
encryptConfigs: (configs: GroupConfig[]) => Promise<unknown>;
|
||||
isCurrent: () => boolean;
|
||||
validateCurrent?: (current: VaultGroupMutationState) => boolean;
|
||||
}): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> {
|
||||
const raw = new Map(GROUP_MUTATION_KEYS.map((key) => [key, storage.readString(key)]));
|
||||
const [hosts, configs] = await Promise.all([
|
||||
decryptHosts(readStoredArray<Host>(STORAGE_KEY_HOSTS, raw.get(STORAGE_KEY_HOSTS) ?? null)),
|
||||
decryptConfigs(readStoredArray<GroupConfig>(
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
raw.get(STORAGE_KEY_GROUP_CONFIGS) ?? null,
|
||||
)),
|
||||
]);
|
||||
const current: VaultGroupMutationState = {
|
||||
groups: readStoredArray<string>(STORAGE_KEY_GROUPS, raw.get(STORAGE_KEY_GROUPS) ?? null),
|
||||
configs,
|
||||
hosts,
|
||||
managedSources: readStoredArray<ManagedSource>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
raw.get(STORAGE_KEY_MANAGED_SOURCES) ?? null,
|
||||
),
|
||||
snippets: readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, raw.get(STORAGE_KEY_SNIPPETS) ?? null),
|
||||
};
|
||||
if (!isCurrent() || (validateCurrent && !validateCurrent(current))) {
|
||||
return { ok: false, superseded: true };
|
||||
}
|
||||
const mutation = mutate(current);
|
||||
if (!mutation.ok) return mutation;
|
||||
|
||||
const nextState = prepareState(mutation.state);
|
||||
const [encryptedHosts, encryptedConfigs] = await Promise.all([
|
||||
encryptHosts(nextState.hosts),
|
||||
encryptConfigs(nextState.configs),
|
||||
]);
|
||||
if (
|
||||
!isCurrent()
|
||||
|| GROUP_MUTATION_KEYS.some((key) => storage.readString(key) !== raw.get(key))
|
||||
) {
|
||||
return { ok: false, superseded: true };
|
||||
}
|
||||
|
||||
commitPluginImporterTransaction(storage, [
|
||||
[STORAGE_KEY_HOSTS, encryptedHosts],
|
||||
[STORAGE_KEY_GROUPS, nextState.groups],
|
||||
[STORAGE_KEY_MANAGED_SOURCES, nextState.managedSources],
|
||||
[STORAGE_KEY_GROUP_CONFIGS, encryptedConfigs],
|
||||
[STORAGE_KEY_SNIPPETS, nextState.snippets],
|
||||
]);
|
||||
return { ok: true, state: nextState };
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface VaultHostTreeActions {
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
commitInlineGroupRename: (name: string) => void;
|
||||
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit: () => void;
|
||||
commitInlineHostRename: (name: string) => void;
|
||||
cancelInlineHostEdit: () => void;
|
||||
|
||||
@@ -81,6 +81,7 @@ test('vault actions register and unregister', () => {
|
||||
readPersistedManagedSources: noop,
|
||||
commitPluginImporterData: noop,
|
||||
commitVaultImportTransaction: noop,
|
||||
commitVaultGroupMutation: noop,
|
||||
updateHostDistro: noop,
|
||||
updateHostLastConnected: noop,
|
||||
addShellHistoryEntry: noop,
|
||||
|
||||
@@ -58,6 +58,7 @@ export type VaultSnapshotActions = Pick<
|
||||
| 'readPersistedManagedSources'
|
||||
| 'commitPluginImporterData'
|
||||
| 'commitVaultImportTransaction'
|
||||
| 'commitVaultGroupMutation'
|
||||
| 'updateHostDistro'
|
||||
| 'updateHostLastConnected'
|
||||
| 'addShellHistoryEntry'
|
||||
|
||||
@@ -290,7 +290,7 @@ interface HostTreeViewProps {
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
@@ -330,7 +330,7 @@ interface TreeNodeProps {
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { CheckSquare, FolderTree, Search, Square } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/application/i18n/I18nProvider';
|
||||
import type { Host } from '@/domain/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FixedSizeVirtualList } from '@/components/ui/FixedSizeVirtualList';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SelectGroupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
selectedGroupPaths: string[];
|
||||
onSelectionChange: (selectedGroupPaths: string[]) => void;
|
||||
}
|
||||
|
||||
const GROUP_ROW_HEIGHT = 48;
|
||||
|
||||
function collectGroupPaths(
|
||||
hosts: Host[],
|
||||
customGroups: string[],
|
||||
selectedGroupPaths: string[],
|
||||
): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const groupPath of [...customGroups, ...selectedGroupPaths]) {
|
||||
const parts = groupPath.split('/').filter(Boolean);
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
paths.add(parts.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
for (const host of hosts) {
|
||||
const parts = host.group?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
paths.add(parts.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
return [...paths].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function countHostsByGroupPath(hosts: Host[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const host of hosts) {
|
||||
if (host.protocol === 'serial') continue;
|
||||
const parts = host.group?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
const path = parts.slice(0, index).join('/');
|
||||
counts.set(path, (counts.get(path) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export const SelectGroupDialog: React.FC<SelectGroupDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
hosts,
|
||||
customGroups = [],
|
||||
selectedGroupPaths,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const selected = useMemo(() => new Set(selectedGroupPaths), [selectedGroupPaths]);
|
||||
const groupPaths = useMemo(
|
||||
() => collectGroupPaths(hosts, customGroups, selectedGroupPaths),
|
||||
[customGroups, hosts, selectedGroupPaths],
|
||||
);
|
||||
const displayedPaths = useMemo(() => {
|
||||
const query = searchQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return groupPaths;
|
||||
return groupPaths.filter((path) => path.toLocaleLowerCase().includes(query));
|
||||
}, [groupPaths, searchQuery]);
|
||||
const hostCountByPath = useMemo(() => countHostsByGroupPath(hosts), [hosts]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
if (selected.has(path)) {
|
||||
onSelectionChange(selectedGroupPaths.filter((candidate) => candidate !== path));
|
||||
return;
|
||||
}
|
||||
onSelectionChange([...selectedGroupPaths, path]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md h-[min(78vh,600px)] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4 pb-3 shrink-0 border-b border-border/60">
|
||||
<DialogTitle className="text-base">{t('snippets.targets.selectGroups')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 py-3 border-b border-border/60 shrink-0">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
{displayedPaths.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t('snippets.targets.noGroups')}
|
||||
</p>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
items={displayedPaths}
|
||||
itemHeight={GROUP_ROW_HEIGHT}
|
||||
getItemKey={(path) => path}
|
||||
renderItem={(path) => {
|
||||
const isSelected = selected.has(path);
|
||||
const depth = Math.max(0, path.split('/').filter(Boolean).length - 1);
|
||||
const label = path.split('/').filter(Boolean).pop() ?? path;
|
||||
const hostCount = hostCountByPath.get(path) ?? 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full h-11 flex items-center gap-2.5 rounded-lg px-2.5 text-left transition-colors',
|
||||
isSelected ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
|
||||
)}
|
||||
style={{ paddingLeft: `${10 + depth * 16}px` }}
|
||||
onClick={() => togglePath(path)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={16} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Square size={16} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<FolderTree size={16} className="text-primary shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{path}</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">
|
||||
{t('vault.groups.hostsCount', { count: hostCount })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t('selectHost.continueWithCount', { count: selectedGroupPaths.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -86,6 +86,7 @@ import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
@@ -560,6 +561,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
activeHost.username,
|
||||
activeHost.sftpFileProtocol,
|
||||
);
|
||||
const pendingSameEndpointSession = sessions.find((session) => (
|
||||
isPendingSameEndpointSshSession(session, activeHost)
|
||||
));
|
||||
if (!activeSessionId && pendingSameEndpointSession) {
|
||||
return;
|
||||
}
|
||||
const sessionChanged = shouldResetSftpSidePanelSourceSession(
|
||||
lastSourceSessionIdRef.current,
|
||||
activeSessionId,
|
||||
@@ -737,7 +744,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive]);
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !isVisible) return;
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
type SnippetExportPayload,
|
||||
type SnippetImportConflictAction,
|
||||
} from '../domain/snippetTransfer';
|
||||
import { getRunnableHostsForSnippet, snippetHasRunTargets } from '../domain/snippetTargets.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
snippetHasRunTargets,
|
||||
} from '../domain/snippetTargets.ts';
|
||||
import { removeHostConnectScript, syncHostsForSnippetTargetChange } from '../domain/hostConnectScripts.ts';
|
||||
import { flattenSnippetCommandPreview } from '../domain/snippetPreview.ts';
|
||||
import { deleteSelectedSnippetsFromVault } from '../domain/snippetSelection.ts';
|
||||
@@ -486,6 +490,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
targets: [],
|
||||
});
|
||||
const [targetSelection, setTargetSelection] = useState<string[]>([]);
|
||||
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
@@ -735,6 +740,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
if (snippet) {
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets || []));
|
||||
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups || []));
|
||||
} else {
|
||||
setEditingSnippet(asScript ? {
|
||||
label: '',
|
||||
@@ -751,6 +757,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
targets: [],
|
||||
});
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
}
|
||||
setRightPanelMode('edit-snippet');
|
||||
}, [selectedPackage]);
|
||||
@@ -773,6 +780,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
tags: editingSnippet.tags || [],
|
||||
package: editingSnippet.package || '',
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
noAutoRun: editingSnippet.noAutoRun,
|
||||
@@ -784,7 +795,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
trigger: editingSnippet.trigger,
|
||||
triggerPattern: editingSnippet.triggerPattern,
|
||||
};
|
||||
}, [editingSnippet, targetSelection]);
|
||||
}, [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const syncHostsAfterSnippetSave = useCallback((
|
||||
savedSnippet: Snippet,
|
||||
@@ -855,6 +866,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
setRightPanelMode('none');
|
||||
setEditingSnippet({ label: '', command: '', package: '', targets: [] });
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
};
|
||||
|
||||
const hostById = useMemo(() => (
|
||||
@@ -1663,6 +1675,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
customGroups={customGroups}
|
||||
targetSelection={targetSelection}
|
||||
setTargetSelection={setTargetSelection}
|
||||
targetGroupSelection={targetGroupSelection}
|
||||
setTargetGroupSelection={setTargetGroupSelection}
|
||||
handleTargetSelect={handleTargetSelect}
|
||||
handleTargetSelectionChange={handleTargetSelectionChange}
|
||||
handleTargetPickerBack={handleTargetPickerBack}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parseSnippetVariables } from '../domain/snippetVariables';
|
||||
import { Check, Clock, Keyboard, Loader2, Package, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { SelectGroupDialog } from './SelectGroupDialog';
|
||||
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
@@ -17,7 +18,10 @@ import { Combobox } from './ui/combobox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { HistoryItem } from './SnippetsHistoryItem';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { getRunnableHostsForSnippet } from '@/domain/snippetTargets.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
} from '@/domain/snippetTargets.ts';
|
||||
import { STORAGE_KEY_SNIPPETS_PANEL_WIDTH } from '@/infrastructure/config/storageKeys.ts';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -29,6 +33,8 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
customGroups,
|
||||
targetSelection,
|
||||
setTargetSelection,
|
||||
targetGroupSelection,
|
||||
setTargetGroupSelection,
|
||||
handleTargetSelect,
|
||||
handleTargetSelectionChange,
|
||||
handleTargetPickerBack,
|
||||
@@ -71,6 +77,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
[editingSnippet?.command],
|
||||
);
|
||||
const [scriptEditorModalOpen, setScriptEditorModalOpen] = useState(false);
|
||||
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
|
||||
const isEditingScript = isScriptSnippet(editingSnippet as import('../types').Snippet);
|
||||
const snippetsPanelResizeProps = {
|
||||
resizable: true as const,
|
||||
@@ -81,8 +88,12 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
const runnableEditingSnippet = useMemo(() => ({
|
||||
...(editingSnippet as Snippet),
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
}), [editingSnippet, targetSelection]);
|
||||
}), [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const runTargets = useMemo(
|
||||
() => getRunnableHostsForSnippet(runnableEditingSnippet, hosts),
|
||||
@@ -99,10 +110,12 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
const handleTargetsAllHostsChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setEditingSnippet({
|
||||
...editingSnippet,
|
||||
targetsAllHosts: true,
|
||||
targets: [],
|
||||
targetGroups: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -137,6 +150,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
|
||||
if (rightPanelMode === 'edit-snippet') {
|
||||
return (
|
||||
<>
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={handleClosePanel}
|
||||
@@ -266,7 +280,9 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
targetGroups={isEditingScript ? targetGroupSelection : []}
|
||||
onEditTargets={openTargetPicker}
|
||||
onEditGroups={isEditingScript ? () => setGroupPickerOpen(true) : undefined}
|
||||
hint={isEditingScript
|
||||
? (editingSnippet.trigger === 'onConnect'
|
||||
? t('scripts.targets.connectOrderHint')
|
||||
@@ -386,11 +402,29 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
selectedHostIds={targetSelection}
|
||||
onSelectHost={handleTargetSelect}
|
||||
onSelectionChange={handleTargetSelectionChange}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onGroupSelectionChange={setTargetGroupSelection}
|
||||
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
|
||||
onTargetsAllHostsChange={handleTargetsAllHostsChange}
|
||||
/>
|
||||
) : null}
|
||||
</AsidePanel>
|
||||
<SelectGroupDialog
|
||||
open={groupPickerOpen}
|
||||
onOpenChange={setGroupPickerOpen}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onSelectionChange={(nextGroups) => {
|
||||
setTargetGroupSelection(nextGroups);
|
||||
setEditingSnippet({
|
||||
...editingSnippet,
|
||||
targetGroups: nextGroups,
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+28
-9
@@ -121,6 +121,7 @@ import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
|
||||
import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
@@ -427,7 +428,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scriptSessionName = sessionDisplayName || host.label;
|
||||
const outputTriggers = useOutputTriggers({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
host,
|
||||
snippets,
|
||||
onRunScript: (snippet, sid) => runAutomationScript({
|
||||
snippet,
|
||||
@@ -438,7 +439,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
},
|
||||
}).catch((err) => {
|
||||
}).then(() => undefined).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
throw err;
|
||||
@@ -1512,6 +1513,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
|
||||
snapshot = serializeAddonRef.current.serialize() || "";
|
||||
} else if (hibernatedRef.current || softHiddenRef.current) {
|
||||
// Hibernate path: live xterm is torn down; use retained snapshot.
|
||||
@@ -1617,7 +1619,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalCwdTracker,
|
||||
]);
|
||||
|
||||
const cleanupSession = async () => {
|
||||
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
|
||||
const closingSessionId = sessionRef.current;
|
||||
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
|
||||
sessionRef.current = null;
|
||||
@@ -1645,7 +1647,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Still notify main so in-flight SSH passphrase prompts for this UI
|
||||
// sessionId are aborted even before a backend session was attached.
|
||||
try {
|
||||
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(sessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending session boot on disconnect", err);
|
||||
}
|
||||
@@ -1692,6 +1697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
let serializedSnapshot: unknown;
|
||||
try {
|
||||
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
|
||||
serializedSnapshot = serializeAddonRef.current?.serialize?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal snapshot for attach popup", err);
|
||||
@@ -1754,7 +1760,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearTerminalSessionFlowAck(closingSessionId);
|
||||
}
|
||||
try {
|
||||
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(closingSessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to close SSH session", err);
|
||||
}
|
||||
@@ -2046,7 +2055,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snapshot = await serializeTerminalForHibernate(
|
||||
term,
|
||||
serializeAddon,
|
||||
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current) },
|
||||
{
|
||||
preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current),
|
||||
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
|
||||
?? Promise.resolve(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!canFinishHibernate()) return false;
|
||||
@@ -2168,6 +2181,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef,
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
prepareKeywordHighlightSerialization: () => xtermRuntimeRef.current
|
||||
?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(),
|
||||
searchAddonRef,
|
||||
hasRuntimeRef,
|
||||
}), []);
|
||||
@@ -3067,6 +3082,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
||||
if (isScriptSnippet(snippet)) {
|
||||
if (!snippetCanRunInTerminal(snippet, host)) {
|
||||
toast.error(t('scripts.targets.currentHostMismatch'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
@@ -3089,7 +3108,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
executeSnippetCommand(command, snippet.noAutoRun, {
|
||||
multiLineRunMode: snippet.multiLineRunMode,
|
||||
});
|
||||
}, [executeSnippetCommand, host.hostname, host.username, scriptSessionName, sessionId, t]);
|
||||
}, [executeSnippetCommand, host, scriptSessionName, sessionId, t]);
|
||||
|
||||
const onSnippetShortkeyRef = useRef(executeSnippet);
|
||||
onSnippetShortkeyRef.current = executeSnippet;
|
||||
@@ -3303,7 +3322,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setPendingHostKeyRequestId(null);
|
||||
setError(null);
|
||||
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
|
||||
void cleanupSession();
|
||||
void cleanupSession({ retainOwnership: true });
|
||||
updateStatus("disconnected");
|
||||
setChainProgress(null);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
@@ -3472,7 +3491,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
await cleanupSession({ retainOwnership: true });
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
throw error;
|
||||
|
||||
@@ -60,48 +60,66 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
managedSources: [],
|
||||
sessionCount: 0,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onOpenSettings: noop,
|
||||
onOpenQuickSwitcher: noop,
|
||||
onCreateLocalTerminal: noop,
|
||||
onDeleteHost: noop,
|
||||
onConnect: noop,
|
||||
onUpdateHosts: noop,
|
||||
onUpdateKeys: noop,
|
||||
onImportOrReuseKey: () => fallbackKey,
|
||||
onUpdateIdentities: noop,
|
||||
onUpdateProxyProfiles: noop,
|
||||
onUpdateSnippets: noop,
|
||||
onUpdateSnippetPackages: noop,
|
||||
onUpdateCustomGroups: noop,
|
||||
onUpdateKnownHosts: noop,
|
||||
onUpdateManagedSources: noop,
|
||||
onConvertKnownHost: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
{
|
||||
locale: "en",
|
||||
children: React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
managedSources: [],
|
||||
sessionCount: 0,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onOpenSettings: noop,
|
||||
onOpenQuickSwitcher: noop,
|
||||
onCreateLocalTerminal: noop,
|
||||
onDeleteHost: noop,
|
||||
onConnect: noop,
|
||||
onUpdateHosts: noop,
|
||||
onUpdateKeys: noop,
|
||||
onImportOrReuseKey: () => fallbackKey,
|
||||
onUpdateIdentities: noop,
|
||||
onUpdateProxyProfiles: noop,
|
||||
onUpdateSnippets: noop,
|
||||
onUpdateSnippetPackages: noop,
|
||||
onUpdateCustomGroups: noop,
|
||||
onUpdateKnownHosts: noop,
|
||||
onUpdateManagedSources: noop,
|
||||
onReadPersistedHosts: async () => hosts,
|
||||
onReadPersistedManagedSources: () => [],
|
||||
onCommitPluginImporterData: async () => 0,
|
||||
onCommitVaultImportTransaction: async () => ({
|
||||
status: "persisted" as const,
|
||||
groups: [],
|
||||
sources: [],
|
||||
groupConfigs: [],
|
||||
}),
|
||||
onCommitVaultGroupMutation: async (mutate) => mutate({
|
||||
groups: [],
|
||||
configs: [],
|
||||
hosts,
|
||||
managedSources: [],
|
||||
snippets: [],
|
||||
}),
|
||||
onConvertKnownHost: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
+91
-113
@@ -52,6 +52,10 @@ import type { VaultLockHandle } from "../application/state/vaultManagedImportLoc
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
|
||||
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../domain/vaultGroupMutation";
|
||||
import type { PluginImporterCommitRequest } from "../application/state/usePluginImporterCommit";
|
||||
import { buildVaultCsvCredentialOptions } from "../application/vaultCsvExportCredentials";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
@@ -69,6 +73,9 @@ import {
|
||||
upsertHostById,
|
||||
} from "../domain/host";
|
||||
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
} from "../domain/hostGroupPathMutations";
|
||||
import {
|
||||
reorderVaultItems,
|
||||
reorderVaultStrings,
|
||||
@@ -261,6 +268,10 @@ interface VaultViewProps {
|
||||
}
|
||||
| { status: "superseded" }
|
||||
>;
|
||||
onCommitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
|
||||
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
|
||||
onUnmanageSource?: (sourceId: string) => void;
|
||||
@@ -325,6 +336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onUpdateManagedSources,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onUnmanageSource,
|
||||
@@ -951,7 +963,66 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsNewFolderOpen(false);
|
||||
};
|
||||
|
||||
const submitRenameGroup = () => {
|
||||
const commitGroupPathChange = useCallback(async (
|
||||
sourcePath: string,
|
||||
nextPath: string,
|
||||
replacementConfig?: GroupConfig,
|
||||
) => {
|
||||
try {
|
||||
return await onCommitVaultGroupMutation((current) => {
|
||||
if (!current.groups.includes(sourcePath)) {
|
||||
return { ok: false, error: `Group "${sourcePath}" was not found.` };
|
||||
}
|
||||
const belongsToTree = (path: string) => (
|
||||
path === sourcePath || path.startsWith(`${sourcePath}/`)
|
||||
);
|
||||
const rename = (path: string) => (
|
||||
path === sourcePath
|
||||
? nextPath
|
||||
: path.startsWith(`${sourcePath}/`)
|
||||
? nextPath + path.slice(sourcePath.length)
|
||||
: path
|
||||
);
|
||||
const occupied = new Set([
|
||||
...current.groups.filter((path) => !belongsToTree(path)),
|
||||
...current.configs.map((config) => config.path).filter((path) => !belongsToTree(path)),
|
||||
]);
|
||||
const collision = [
|
||||
...current.groups,
|
||||
...current.configs.map((config) => config.path),
|
||||
].filter(belongsToTree).map(rename).find((path) => occupied.has(path));
|
||||
if (collision) return { ok: false, error: `Group "${collision}" already exists.` };
|
||||
|
||||
const configs = current.configs.map((config) => {
|
||||
if (replacementConfig && config.path === sourcePath) {
|
||||
return { ...replacementConfig, path: nextPath };
|
||||
}
|
||||
return belongsToTree(config.path) ? { ...config, path: rename(config.path) } : config;
|
||||
});
|
||||
if (replacementConfig && !current.configs.some((config) => config.path === sourcePath)) {
|
||||
configs.push({ ...replacementConfig, path: nextPath });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: Array.from(new Set(current.groups.map(rename))),
|
||||
configs,
|
||||
hosts: current.hosts.map((host) => (
|
||||
host.group && belongsToTree(host.group) ? { ...host, group: rename(host.group) } : host
|
||||
)),
|
||||
managedSources: current.managedSources.map((source) => (
|
||||
belongsToTree(source.groupName) ? { ...source, groupName: rename(source.groupName) } : source
|
||||
)),
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, sourcePath, nextPath),
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}, [onCommitVaultGroupMutation]);
|
||||
|
||||
const submitRenameGroup = async () => {
|
||||
if (!renameTargetPath) return;
|
||||
|
||||
const nextName = renameGroupName.trim();
|
||||
@@ -972,37 +1043,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === renameTargetPath) return nextPath;
|
||||
if (g.startsWith(renameTargetPath + "/"))
|
||||
return nextPath + g.slice(renameTargetPath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === renameTargetPath) return { ...h, group: nextPath };
|
||||
if (g.startsWith(renameTargetPath + "/"))
|
||||
return { ...h, group: nextPath + g.slice(renameTargetPath.length) };
|
||||
return h;
|
||||
});
|
||||
|
||||
// Update managed sources if any match the renamed group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === renameTargetPath)
|
||||
return { ...s, groupName: nextPath };
|
||||
if (s.groupName.startsWith(renameTargetPath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: nextPath + s.groupName.slice(renameTargetPath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
const result = await commitGroupPathChange(renameTargetPath, nextPath);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
setRenameGroupError("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
if (
|
||||
selectedGroupPath &&
|
||||
(selectedGroupPath === renameTargetPath ||
|
||||
@@ -1026,7 +1071,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleSaveGroupConfig = useCallback(
|
||||
(config: GroupConfig, _newName?: string, _newParent?: string | null) => {
|
||||
async (config: GroupConfig, _newName?: string, _newParent?: string | null) => {
|
||||
const oldPath = editingGroupPath!;
|
||||
const newPath = config.path; // Panel already computed the correct path
|
||||
|
||||
@@ -1044,41 +1089,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
// Handle path change (rename or parent change)
|
||||
if (newPath !== oldPath) {
|
||||
// Update groups, hosts, managed sources, and configs for path change
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === oldPath) return newPath;
|
||||
if (g.startsWith(oldPath + "/"))
|
||||
return newPath + g.slice(oldPath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === oldPath) return { ...h, group: newPath };
|
||||
if (g.startsWith(oldPath + "/"))
|
||||
return { ...h, group: newPath + g.slice(oldPath.length) };
|
||||
return h;
|
||||
});
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === oldPath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(oldPath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: newPath + s.groupName.slice(oldPath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
const result = await commitGroupPathChange(oldPath, newPath, config);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
toast.error("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
// Update child config paths too
|
||||
const finalConfigs = updatedConfigs.map((c) => {
|
||||
if (c.path.startsWith(oldPath + "/"))
|
||||
return { ...c, path: newPath + c.path.slice(oldPath.length) };
|
||||
return c;
|
||||
});
|
||||
onUpdateGroupConfigs(finalConfigs);
|
||||
if (selectedGroupPath === oldPath) setSelectedGroupPath(newPath);
|
||||
if (selectedGroupPath?.startsWith(oldPath + "/")) {
|
||||
setSelectedGroupPath(
|
||||
@@ -1096,13 +1111,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
groupConfigs,
|
||||
editingGroupPath,
|
||||
customGroups,
|
||||
hosts,
|
||||
managedSources,
|
||||
selectedGroupPath,
|
||||
commitGroupPathChange,
|
||||
onUpdateGroupConfigs,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1129,7 +1140,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
managedSources,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onDeletedPaths: handleDeletedGroupPaths,
|
||||
@@ -1138,7 +1149,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const deleteGroupPath = (path: string, deleteHosts: boolean = false) =>
|
||||
deleteGroupPaths([path], deleteHosts);
|
||||
|
||||
const moveGroup = (sourcePath: string, targetParent: string | null) => {
|
||||
const moveGroup = async (sourcePath: string, targetParent: string | null) => {
|
||||
const name = sourcePath.split("/").filter(Boolean).pop() || "";
|
||||
const newPath = targetParent ? `${targetParent}/${name}` : name;
|
||||
if (newPath === sourcePath || newPath.startsWith(sourcePath + "/")) return;
|
||||
@@ -1146,50 +1157,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
toast.error(t("vault.groups.errors.duplicatePath"));
|
||||
return;
|
||||
}
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === sourcePath) return newPath;
|
||||
if (g.startsWith(sourcePath + "/"))
|
||||
return newPath + g.slice(sourcePath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === sourcePath) return { ...h, group: newPath };
|
||||
if (g.startsWith(sourcePath + "/"))
|
||||
return { ...h, group: newPath + g.slice(sourcePath.length) };
|
||||
return h;
|
||||
});
|
||||
// Update managed sources if any match the moved group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === sourcePath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(sourcePath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: newPath + s.groupName.slice(sourcePath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
// Update group configs for moved paths
|
||||
const updatedGroupConfigs = groupConfigs.map((c) => {
|
||||
if (c.path === sourcePath) return { ...c, path: newPath };
|
||||
if (c.path.startsWith(sourcePath + "/"))
|
||||
return { ...c, path: newPath + c.path.slice(sourcePath.length) };
|
||||
return c;
|
||||
});
|
||||
if (updatedGroupConfigs.some((c, i) => c !== groupConfigs[i])) {
|
||||
onUpdateGroupConfigs(updatedGroupConfigs);
|
||||
const result = await commitGroupPathChange(sourcePath, newPath);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
toast.error("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedGroupPath &&
|
||||
(selectedGroupPath === sourcePath ||
|
||||
selectedGroupPath.startsWith(sourcePath + "/"))
|
||||
) {
|
||||
setSelectedGroupPath(newPath);
|
||||
setSelectedGroupPath(newPath + selectedGroupPath.slice(sourcePath.length));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1321,8 +1299,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
onCommitGroupPathChange: commitGroupPathChange,
|
||||
selectedGroupPath,
|
||||
setSelectedGroupPath,
|
||||
ensurePathExpanded: treeExpandedState.ensurePathExpanded,
|
||||
@@ -1688,6 +1665,7 @@ export const vaultViewAreEqual = (
|
||||
prev.onReadPersistedHosts === next.onReadPersistedHosts &&
|
||||
prev.onReadPersistedManagedSources === next.onReadPersistedManagedSources &&
|
||||
prev.onCommitVaultImportTransaction === next.onCommitVaultImportTransaction &&
|
||||
prev.onCommitVaultGroupMutation === next.onCommitVaultGroupMutation &&
|
||||
prev.onCommitPluginImporterData === next.onCommitPluginImporterData &&
|
||||
prev.showRecentHosts === next.showRecentHosts &&
|
||||
prev.hostClickBehavior === next.hostClickBehavior &&
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Globe, Link2, Play, Trash2 } from 'lucide-react';
|
||||
import { FolderTree, Globe, Link2, Play, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import {
|
||||
appendHostConnectScript,
|
||||
getEditableHostConnectScriptIds,
|
||||
getGlobalConnectScripts,
|
||||
getGroupConnectScriptsForHost,
|
||||
removeHostConnectScript,
|
||||
reorderHostConnectScript,
|
||||
} from '@/domain/hostConnectScripts.ts';
|
||||
@@ -63,6 +64,16 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
() => getEditableHostConnectScriptIds(host, snippets),
|
||||
[host, snippets],
|
||||
);
|
||||
const groupScripts = useMemo(
|
||||
() => getGroupConnectScriptsForHost(host, snippets).filter(
|
||||
(script) => !script.id || !queueIds.includes(script.id),
|
||||
),
|
||||
[host, queueIds, snippets],
|
||||
);
|
||||
const groupScriptIds = useMemo(
|
||||
() => new Set(groupScripts.map((script) => script.id)),
|
||||
[groupScripts],
|
||||
);
|
||||
const queuedScripts = useMemo(
|
||||
() => queueIds
|
||||
.map((id) => scriptById(snippets, id))
|
||||
@@ -73,10 +84,11 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
() => scripts.filter((script) => {
|
||||
if (!script.id) return false;
|
||||
if (script.targetsAllHosts) return false;
|
||||
if (groupScriptIds.has(script.id)) return false;
|
||||
if (queueIds.includes(script.id)) return false;
|
||||
return true;
|
||||
}),
|
||||
[queueIds, scripts],
|
||||
[groupScriptIds, queueIds, scripts],
|
||||
);
|
||||
const linkOptions = useMemo(
|
||||
() => linkableScripts.map((script) => ({
|
||||
@@ -170,6 +182,41 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupScripts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FolderTree size={12} className="text-muted-foreground shrink-0" />
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('hostDetails.automation.groupScripts')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t('hostDetails.automation.groupScriptsHint')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{groupScripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/50 bg-primary/5"
|
||||
>
|
||||
<VaultEntityIcon
|
||||
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
|
||||
icon={<Play size={14} />}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{script.label || t('scripts.running.unnamed')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{script.targetGroups?.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{queuedScripts.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t('hostDetails.automation.connectQueueEmpty')}</p>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { HostTreeGroupInlineRenameInput } from './HostTreeGroupInlineRenameInput';
|
||||
|
||||
test('inline group rename can retry after an asynchronous commit failure', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(HostTreeGroupInlineRenameInput, {
|
||||
initialName: 'prod',
|
||||
onCommit: async () => {
|
||||
attempts += 1;
|
||||
return attempts > 1;
|
||||
},
|
||||
onCancel: () => undefined,
|
||||
}));
|
||||
});
|
||||
const input = renderer!.root.findByType('input');
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { cn } from '../../lib/utils';
|
||||
|
||||
type HostTreeGroupInlineRenameInputProps = {
|
||||
initialName: string;
|
||||
onCommit: (name: string) => void;
|
||||
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -19,7 +19,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialName);
|
||||
const committedRef = useRef(false);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
@@ -28,15 +28,20 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(value);
|
||||
const commit = async () => {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
try {
|
||||
const committed = await onCommit(value);
|
||||
if (committed === false) submittingRef.current = false;
|
||||
} catch {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@@ -49,7 +54,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
void commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -64,7 +69,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/domain/snippetScript.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
} from '@/domain/snippetTargets.ts';
|
||||
import {
|
||||
removeHostConnectScript,
|
||||
@@ -54,11 +55,13 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>(createBlankScript);
|
||||
const [targetSelection, setTargetSelection] = useState<string[]>([]);
|
||||
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setEditingSnippet(createBlankScript());
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:scripts:add', handler);
|
||||
@@ -71,6 +74,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
if (!snippet || !isScriptSnippet(snippet)) return;
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets ?? []));
|
||||
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups ?? []));
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
@@ -112,6 +116,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
if (detail.editAfterSave) {
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -132,8 +137,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const runnableSnippet = useMemo(() => ({
|
||||
...(editingSnippet as Snippet),
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
}), [editingSnippet, targetSelection]);
|
||||
}), [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const runTargets = useMemo(
|
||||
() => getRunnableHostsForSnippet(runnableSnippet, hosts),
|
||||
@@ -174,6 +183,10 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
tags: editingSnippet.tags ?? [],
|
||||
package: packagePath,
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
kind: 'script',
|
||||
language: editingSnippet.language ?? 'javascript',
|
||||
@@ -182,7 +195,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
triggerPattern: editingSnippet.triggerPattern,
|
||||
order: editingSnippet.order,
|
||||
};
|
||||
}, [editingSnippet, onCreatePackage, packages, targetSelection]);
|
||||
}, [editingSnippet, onCreatePackage, packages, targetGroupSelection, targetSelection]);
|
||||
|
||||
const persistSnippet = useCallback((): Snippet | null => {
|
||||
const savedSnippet = buildSavedSnippet();
|
||||
@@ -238,10 +251,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const handleTargetsAllHostsChange = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setEditingSnippet((prev) => ({
|
||||
...prev,
|
||||
targetsAllHosts: true,
|
||||
targets: [],
|
||||
targetGroups: undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -266,6 +281,8 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
selectedHostIds={targetSelection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onGroupSelectionChange={setTargetGroupSelection}
|
||||
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
|
||||
onTargetsAllHostsChange={handleTargetsAllHostsChange}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,11 @@ import type { Host, Snippet } from '@/domain/models';
|
||||
import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts';
|
||||
import { scheduleWindowInputFocus } from '@/application/state/windowInputFocus';
|
||||
import { SelectHostDialog } from '@/components/SelectHostDialog';
|
||||
import { SelectGroupDialog } from '@/components/SelectGroupDialog';
|
||||
import { ScriptCodeEditor } from './ScriptCodeEditor';
|
||||
import { ScriptMetaFields } from './ScriptMetaFields';
|
||||
import { SnippetTargetsSection } from '@/components/snippets/SnippetTargetsSection';
|
||||
import { resolveSnippetTargetGroupsForSave } from '@/domain/snippetTargets.ts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -26,6 +28,8 @@ export interface ScriptEditorModalProps {
|
||||
selectedHostIds: string[];
|
||||
onSelectHost: (host: Host) => void;
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
selectedGroupPaths?: string[];
|
||||
onGroupSelectionChange?: (selectedGroupPaths: string[]) => void;
|
||||
targetsAllHosts?: boolean;
|
||||
onTargetsAllHostsChange?: (checked: boolean) => void;
|
||||
}
|
||||
@@ -53,11 +57,14 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
selectedHostIds,
|
||||
onSelectHost,
|
||||
onSelectionChange,
|
||||
selectedGroupPaths = [],
|
||||
onGroupSelectionChange,
|
||||
targetsAllHosts = false,
|
||||
onTargetsAllHostsChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
||||
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
@@ -81,9 +88,10 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
onChange({
|
||||
...snippet,
|
||||
targets: selectedHostIds,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(snippet, selectedGroupPaths),
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}, [onChange, selectedHostIds, snippet]);
|
||||
}, [onChange, selectedGroupPaths, selectedHostIds, snippet]);
|
||||
|
||||
const language = 'javascript';
|
||||
const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE;
|
||||
@@ -146,9 +154,13 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
variant="embedded"
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
targetGroups={selectedGroupPaths}
|
||||
onEditTargets={() => {
|
||||
if (!targetsAllHosts) setTargetPickerOpen(true);
|
||||
}}
|
||||
onEditGroups={onGroupSelectionChange ? () => {
|
||||
if (!targetsAllHosts) setGroupPickerOpen(true);
|
||||
} : undefined}
|
||||
hint={t('scripts.targets.hint')}
|
||||
targetsAllHosts={targetsAllHosts}
|
||||
onTargetsAllHostsChange={onTargetsAllHostsChange}
|
||||
@@ -192,6 +204,21 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConfirm={handleTargetsConfirm}
|
||||
/>
|
||||
<SelectGroupDialog
|
||||
open={groupPickerOpen}
|
||||
onOpenChange={setGroupPickerOpen}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
selectedGroupPaths={selectedGroupPaths}
|
||||
onSelectionChange={(nextGroups) => {
|
||||
onGroupSelectionChange?.(nextGroups);
|
||||
onChange({
|
||||
...snippet,
|
||||
targetGroups: nextGroups,
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
|
||||
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
@@ -150,7 +151,7 @@ export const ModelSelector: React.FC<{
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${effectiveModelsEndpoint}`;
|
||||
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -509,6 +509,9 @@ export const ProviderConfigForm: React.FC<{
|
||||
placeholder={preset?.defaultBaseURL || "https://"}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
{resolvedStyle === "anthropic" ? (
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
|
||||
@@ -50,7 +50,7 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
|
||||
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
|
||||
});
|
||||
|
||||
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
|
||||
assert.match(source, /sftpSourceSessionIdForHost/);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -60,11 +60,6 @@ test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
source,
|
||||
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
|
||||
);
|
||||
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("sftp host picker virtualizes the host list", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
isRemoteSftpTabHealthy,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
@@ -77,6 +78,35 @@ test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endp
|
||||
);
|
||||
});
|
||||
|
||||
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
};
|
||||
const baseSession = {
|
||||
hostId: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
|
||||
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
|
||||
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
|
||||
|
||||
@@ -33,6 +33,36 @@ export function shouldSkipSftpSidePanelAutoConnect(
|
||||
return isRemoteSftpTabHealthy(activeTab, hasBackendSession);
|
||||
}
|
||||
|
||||
export function isPendingSameEndpointSshSession(
|
||||
session: {
|
||||
hostId?: string | null;
|
||||
status?: string;
|
||||
hostname?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
protocol?: string | null;
|
||||
moshEnabled?: boolean;
|
||||
etEnabled?: boolean;
|
||||
},
|
||||
host: {
|
||||
id: string;
|
||||
hostname: string;
|
||||
port?: number | null;
|
||||
username: string;
|
||||
},
|
||||
): boolean {
|
||||
return Boolean(
|
||||
session.hostId === host.id
|
||||
&& session.status === "connecting"
|
||||
&& session.hostname === host.hostname
|
||||
&& (session.port ?? 22) === (host.port ?? 22)
|
||||
&& session.username === host.username
|
||||
&& (session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
&& !session.etEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a stored endpoint key still belongs to the live connection's host. */
|
||||
export function connectionKeyMatchesHost(
|
||||
connectionKey: string | null | undefined,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SnippetTargetsSection } from './SnippetTargetsSection';
|
||||
|
||||
const t = (key: string) => ({
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.selectHosts': 'Select hosts',
|
||||
'snippets.targets.selectGroups': 'Select groups',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.allHosts': 'All hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Every host',
|
||||
}[key] ?? key);
|
||||
|
||||
test('renders dynamic group targets separately from explicit hosts', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={[]}
|
||||
targetGroups={['Production/Web']}
|
||||
onEditTargets={() => undefined}
|
||||
onEditGroups={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.match(markup, /Production\/Web/);
|
||||
assert.match(markup, /Select hosts/);
|
||||
assert.match(markup, /Select groups/);
|
||||
});
|
||||
|
||||
test('all-host mode hides host and group pickers', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={[]}
|
||||
targetGroups={['Production']}
|
||||
onEditTargets={() => undefined}
|
||||
onEditGroups={() => undefined}
|
||||
targetsAllHosts
|
||||
onTargetsAllHostsChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.doesNotMatch(markup, /Select hosts/);
|
||||
assert.doesNotMatch(markup, /Select groups/);
|
||||
assert.match(markup, /Every host/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FolderTree, Server } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,6 +11,8 @@ export interface SnippetTargetsSectionProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
targetHosts: Host[];
|
||||
onEditTargets: () => void;
|
||||
targetGroups?: string[];
|
||||
onEditGroups?: () => void;
|
||||
hint?: string;
|
||||
variant?: 'card' | 'embedded';
|
||||
targetsAllHosts?: boolean;
|
||||
@@ -25,6 +28,8 @@ const TargetsBody: React.FC<{
|
||||
t: SnippetTargetsSectionProps['t'];
|
||||
targetHosts: Host[];
|
||||
onEditTargets: () => void;
|
||||
targetGroups?: string[];
|
||||
onEditGroups?: () => void;
|
||||
hint?: string;
|
||||
embedded?: boolean;
|
||||
targetsAllHosts?: boolean;
|
||||
@@ -33,6 +38,8 @@ const TargetsBody: React.FC<{
|
||||
t,
|
||||
targetHosts,
|
||||
onEditTargets,
|
||||
targetGroups = [],
|
||||
onEditGroups,
|
||||
hint,
|
||||
embedded = false,
|
||||
targetsAllHosts = false,
|
||||
@@ -50,14 +57,28 @@ const TargetsBody: React.FC<{
|
||||
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{!targetsAllHosts ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary')}
|
||||
onClick={onEditTargets}
|
||||
>
|
||||
{targetHosts.length > 0 ? t('action.edit') : t('snippets.targets.add')}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
|
||||
onClick={onEditTargets}
|
||||
>
|
||||
<Server size={12} />
|
||||
{t('snippets.targets.selectHosts')}
|
||||
</Button>
|
||||
{onEditGroups ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
|
||||
onClick={onEditGroups}
|
||||
>
|
||||
<FolderTree size={12} />
|
||||
{t('snippets.targets.selectGroups')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{onTargetsAllHostsChange ? (
|
||||
@@ -91,7 +112,7 @@ const TargetsBody: React.FC<{
|
||||
>
|
||||
{t('snippets.targets.allHostsActive')}
|
||||
</p>
|
||||
) : targetHosts.length === 0 ? (
|
||||
) : targetHosts.length === 0 && targetGroups.length === 0 ? (
|
||||
embedded ? null : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -103,6 +124,18 @@ const TargetsBody: React.FC<{
|
||||
)
|
||||
) : (
|
||||
<div className={cn('gap-2', embedded ? 'flex flex-wrap' : 'space-y-2')}>
|
||||
{targetGroups.map((groupPath) => (
|
||||
<div
|
||||
key={`group:${groupPath}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border border-border/60 bg-primary/5 max-w-full',
|
||||
embedded ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
<FolderTree size={14} className="text-primary shrink-0" />
|
||||
<span className="truncate font-medium">{groupPath}</span>
|
||||
</div>
|
||||
))}
|
||||
{targetHosts.map((host) => (
|
||||
embedded ? (
|
||||
<div
|
||||
@@ -133,6 +166,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
|
||||
t,
|
||||
targetHosts,
|
||||
onEditTargets,
|
||||
targetGroups,
|
||||
onEditGroups,
|
||||
hint,
|
||||
variant = 'card',
|
||||
targetsAllHosts,
|
||||
@@ -143,6 +178,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
onEditTargets={onEditTargets}
|
||||
targetGroups={targetGroups}
|
||||
onEditGroups={onEditGroups}
|
||||
hint={hint}
|
||||
embedded={variant === 'embedded'}
|
||||
targetsAllHosts={targetsAllHosts}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface TerminalAutocompleteProps {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostGroup?: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
protocol?: string;
|
||||
@@ -63,6 +64,7 @@ export function TerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings,
|
||||
protocol,
|
||||
@@ -119,6 +121,7 @@ export function TerminalAutocomplete({
|
||||
...(options.cwd ? { cwd: options.cwd } : {}),
|
||||
},
|
||||
hostOs,
|
||||
hostGroup,
|
||||
cwdSource: options.cwdSource,
|
||||
snippets: options.snippets,
|
||||
maximum: options.maxResults ?? 15,
|
||||
@@ -126,12 +129,13 @@ export function TerminalAutocomplete({
|
||||
signal: options.signal,
|
||||
onLatePathSuggestions: options.onLatePathSuggestions,
|
||||
});
|
||||
}, [allowHostStyleGreaterThanPrompt, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
containerRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings,
|
||||
onAcceptText,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { formatNetSpeed } from './terminalHelpers';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { formatDiskCapacityGb, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
|
||||
interface TerminalServerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -263,7 +263,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
diskSummary.percent !== null && diskSummary.percent >= 80 && diskSummary.percent < 90 && "text-amber-400"
|
||||
)}>
|
||||
{diskSummary.used !== null && diskSummary.total !== null && diskSummary.percent !== null
|
||||
? `${formatDiskCapacityGb(diskSummary.used)}/${formatDiskCapacityGb(diskSummary.total)}G (${diskSummary.percent}%)`
|
||||
? `${formatDiskCapacityRange(diskSummary.used, diskSummary.total)} (${diskSummary.percent}%)`
|
||||
: diskSummary.percent !== null
|
||||
? `${diskSummary.percent}%`
|
||||
: '--'}
|
||||
@@ -295,7 +295,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
"text-[11px] font-medium whitespace-nowrap",
|
||||
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{formatDiskCapacityGb(disk.used)}/{formatDiskCapacityGb(disk.total)}G ({disk.percent}%)
|
||||
{formatDiskCapacityRange(disk.used, disk.total)} ({disk.percent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
|
||||
@@ -394,7 +394,7 @@ test("manual disconnect keeps the session pane for reconnect", () => {
|
||||
assert.match(body, /isBootActiveRef\.current = false/);
|
||||
assert.match(body, /setIsCancelling\(true\)/);
|
||||
assert.match(body, /updateStatus\("disconnected"\)/);
|
||||
assert.match(body, /void cleanupSession\(\)/);
|
||||
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
|
||||
assert.match(source, /trackSessionCleanup/);
|
||||
assert.doesNotMatch(body, /onCloseSession/);
|
||||
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
|
||||
|
||||
@@ -993,6 +993,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
termRef={termRef}
|
||||
sessionId={sessionId}
|
||||
hostId={host.id}
|
||||
hostGroup={host.group}
|
||||
hostOs={autocompleteHostOs}
|
||||
settings={autocompleteSettings}
|
||||
protocol={effectiveTerminalProtocol ?? resolveEffectiveTerminalProtocol(host)}
|
||||
|
||||
@@ -242,6 +242,7 @@ export async function getCompletions(
|
||||
input: string,
|
||||
options: {
|
||||
hostId?: string;
|
||||
hostGroup?: string;
|
||||
os?: "linux" | "windows" | "macos";
|
||||
maxResults?: number;
|
||||
/** Session ID for remote path completion */
|
||||
@@ -397,7 +398,10 @@ export async function getCompletions(
|
||||
// a snippet's label collides with an existing history entry's text, the
|
||||
// score-sort + final dedup below keeps the snippet (the higher-scored one).
|
||||
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, { hostId })) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
|
||||
hostId,
|
||||
hostGroup: options.hostGroup,
|
||||
})) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,31 +7,37 @@
|
||||
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
|
||||
*/
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
|
||||
import { matchesSearchQuery } from "../../../lib/searchMatcher";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
|
||||
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
|
||||
const SNIPPET_PREFIX_BONUS = 100;
|
||||
|
||||
function snippetAvailableForAutocomplete(snippet: Snippet, hostId?: string): boolean {
|
||||
function snippetAvailableForAutocomplete(
|
||||
snippet: Snippet,
|
||||
host: { hostId?: string; hostGroup?: string },
|
||||
): boolean {
|
||||
if (snippet.targetsAllHosts) return true;
|
||||
if (snippet.targets && snippet.targets.length > 0) {
|
||||
return hostId !== undefined && snippet.targets.includes(hostId);
|
||||
}
|
||||
return true;
|
||||
const hasScopedTargets = Boolean(
|
||||
snippet.targets?.length || snippet.targetGroups !== undefined,
|
||||
);
|
||||
if (!hasScopedTargets) return true;
|
||||
if (!host.hostId) return false;
|
||||
return snippetAppliesToHost(snippet, { id: host.hostId, group: host.hostGroup });
|
||||
}
|
||||
|
||||
export function getSnippetSuggestions(
|
||||
input: string,
|
||||
snippets: Snippet[],
|
||||
options: { hostId?: string } = {},
|
||||
options: { hostId?: string; hostGroup?: string } = {},
|
||||
): CompletionSuggestion[] {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(snippets)) return [];
|
||||
|
||||
const out: CompletionSuggestion[] = [];
|
||||
for (const snippet of snippets) {
|
||||
if (!snippetAvailableForAutocomplete(snippet, options.hostId)) continue;
|
||||
if (!snippetAvailableForAutocomplete(snippet, options)) continue;
|
||||
const label = (snippet.label || "").toLowerCase();
|
||||
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AutocompleteHistoryScope, Snippet } from '../../../domain/models';
|
||||
export interface TerminalCompletionProviderRequest {
|
||||
input: string;
|
||||
session: NetcattyTerminalSessionSnapshot;
|
||||
hostGroup?: string;
|
||||
hostOs: 'linux' | 'windows' | 'macos';
|
||||
cwdSource?: AutocompleteCwdSource;
|
||||
snippets?: Snippet[];
|
||||
@@ -63,6 +64,7 @@ export async function provideTerminalCompletions(
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const builtInPromise = getCompletions(request.input, {
|
||||
hostId: request.session.hostId,
|
||||
hostGroup: request.hostGroup,
|
||||
os: request.hostOs,
|
||||
maxResults: request.maximum,
|
||||
sessionId: request.session.sessionId,
|
||||
|
||||
@@ -149,6 +149,7 @@ interface UseTerminalAutocompleteOptions {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostGroup?: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
/** Callback to write text to the terminal session — replaces CustomEvent */
|
||||
@@ -200,6 +201,7 @@ export function useTerminalAutocomplete(
|
||||
containerRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings: userSettings,
|
||||
onAcceptText,
|
||||
@@ -237,6 +239,8 @@ export function useTerminalAutocomplete(
|
||||
onAcceptSnippetRef.current = onAcceptSnippet;
|
||||
const hostIdRef = useRef(hostId);
|
||||
hostIdRef.current = hostId;
|
||||
const hostGroupRef = useRef(hostGroup);
|
||||
hostGroupRef.current = hostGroup;
|
||||
const hostOsRef = useRef(hostOs);
|
||||
hostOsRef.current = hostOs;
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
@@ -1048,6 +1052,7 @@ export function useTerminalAutocomplete(
|
||||
try {
|
||||
completions = await provideCompletionsRef.current(input, {
|
||||
hostId: hostIdRef.current,
|
||||
hostGroup: hostGroupRef.current,
|
||||
os: hostOsRef.current,
|
||||
maxResults: settingsRef.current.maxSuggestions,
|
||||
historyScope: settingsRef.current.historyScope,
|
||||
|
||||
@@ -19,6 +19,27 @@ test("getCompletions does not surface snippets past the command position", async
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
test("getCompletions applies dynamic group targets to the current host", async () => {
|
||||
const groupedSnippet: Snippet = {
|
||||
...deploySnippet,
|
||||
targetGroups: ["Production"],
|
||||
};
|
||||
|
||||
const matching = await getCompletions("dep", {
|
||||
hostId: "host-prod",
|
||||
hostGroup: "Production/Web",
|
||||
snippets: [groupedSnippet],
|
||||
});
|
||||
const outside = await getCompletions("dep", {
|
||||
hostId: "host-dev",
|
||||
hostGroup: "Development",
|
||||
snippets: [groupedSnippet],
|
||||
});
|
||||
|
||||
assert.ok(matching.some((suggestion) => suggestion.source === "snippet"));
|
||||
assert.equal(outside.some((suggestion) => suggestion.source === "snippet"), false);
|
||||
});
|
||||
|
||||
test("getCompletions returns more than 8 snippet matches when default maxSuggestions allows it", async () => {
|
||||
const snippets: Snippet[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+791
-1854
File diff suppressed because it is too large.
Load diff
@@ -211,6 +211,68 @@ test('visual Provider host preserves semantics when command completion wins the
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
test('visual Provider host restores annotations after terminal history is rebuilt', async () => {
|
||||
const markers: Array<{ line: number; isDisposed: boolean; dispose(): void }> = [];
|
||||
const renderedText: string[] = [];
|
||||
const term = {
|
||||
element: null,
|
||||
buffer: { active: { type: 'normal', baseY: 4, cursorY: 2, cursorX: 0, getLine() { return undefined; } } },
|
||||
onWriteParsed() { return { dispose() {} }; },
|
||||
registerMarker(offset: number) {
|
||||
const marker = {
|
||||
line: 6 + offset,
|
||||
isDisposed: false,
|
||||
dispose() { this.isDisposed = true; this.line = -1; },
|
||||
};
|
||||
markers.push(marker);
|
||||
return marker;
|
||||
},
|
||||
registerDecoration() {
|
||||
return {
|
||||
dispose() {},
|
||||
onRender(listener: (element: Record<string, unknown>) => void) {
|
||||
const element = {
|
||||
className: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
style: {},
|
||||
setAttribute() {},
|
||||
};
|
||||
listener(element);
|
||||
renderedText.push(String(element.textContent));
|
||||
return { dispose() {} };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const host = new PluginTerminalVisualProviderHost({
|
||||
term: term as never,
|
||||
isProviderAvailable: (kind) => kind === 'terminal.semantic',
|
||||
async request() {
|
||||
return {
|
||||
stale: false,
|
||||
results: [{ providerId: 'semantic', status: 'ok', result: {
|
||||
classification: 'success',
|
||||
annotations: [{ text: 'retained annotation' }],
|
||||
} }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await host.commandSubmitted('true');
|
||||
await host.commandCompleted();
|
||||
assert.deepEqual(renderedText, ['success | retained annotation']);
|
||||
markers[0].dispose();
|
||||
|
||||
host.terminalRebuilt();
|
||||
assert.deepEqual(renderedText, [
|
||||
'success | retained annotation',
|
||||
'success | retained annotation',
|
||||
]);
|
||||
assert.equal(markers[1].line, 6);
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
test('visual Provider host preserves semantic queue order at its bounded capacity', async () => {
|
||||
let requests = 0;
|
||||
const renderedText: string[] = [];
|
||||
|
||||
@@ -134,6 +134,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
readonly #disposables: IDisposable[] = [];
|
||||
readonly #matcherDecorations: Array<IDecoration | IMarker | undefined> = [];
|
||||
readonly #annotationDecorations: Array<IDecoration | IMarker | undefined> = [];
|
||||
#visibleAnnotations: readonly PluginTerminalAnnotation[] = Object.freeze([]);
|
||||
#annotationAnchorLine: number | null = null;
|
||||
#annotationMarker: IMarker | null = null;
|
||||
readonly #requestControllers = new Set<AbortController>();
|
||||
readonly #matcherRequestControllers = new Set<AbortController>();
|
||||
#matcherTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -196,6 +199,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
}
|
||||
this.#isProviderAvailable = options.isProviderAvailable ?? (() => true);
|
||||
this.#disposables.push(this.#term.onWriteParsed(() => {
|
||||
if (this.#annotationMarker && !this.#annotationMarker.isDisposed) {
|
||||
this.#annotationAnchorLine = this.#annotationMarker.line;
|
||||
}
|
||||
this.#matcherGeneration += 1;
|
||||
for (const controller of this.#matcherRequestControllers) controller.abort();
|
||||
this.#matcherRequestControllers.clear();
|
||||
@@ -432,11 +438,23 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
#renderAnnotations(annotations: readonly PluginTerminalAnnotation[]): void {
|
||||
#renderAnnotations(
|
||||
annotations: readonly PluginTerminalAnnotation[],
|
||||
anchorLine?: number | null,
|
||||
): void {
|
||||
disposeAll(this.#annotationDecorations);
|
||||
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) return;
|
||||
const marker = this.#term.registerMarker(0);
|
||||
this.#annotationMarker = null;
|
||||
this.#visibleAnnotations = Object.freeze([...annotations]);
|
||||
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) {
|
||||
this.#annotationAnchorLine = null;
|
||||
return;
|
||||
}
|
||||
const buffer = this.#term.buffer.active;
|
||||
const cursorLine = buffer.baseY + buffer.cursorY;
|
||||
const marker = this.#term.registerMarker((anchorLine ?? cursorLine) - cursorLine);
|
||||
if (!marker) return;
|
||||
this.#annotationMarker = marker;
|
||||
this.#annotationAnchorLine = marker.line;
|
||||
const decoration = this.#term.registerDecoration({ marker, anchor: 'right', x: 0, width: 1, layer: 'top' });
|
||||
const text = annotations.map((annotation) => annotation.text).join(' | ').slice(0, 512);
|
||||
const description = annotations.map((annotation) => annotation.description).filter(Boolean).join('\n');
|
||||
@@ -454,6 +472,19 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#annotationDecorations.push(decoration, marker);
|
||||
}
|
||||
|
||||
/** Recreate markers disposed by a terminal history replay without re-querying plugins. */
|
||||
terminalRebuilt(): void {
|
||||
if (this.#visibleAnnotations.length === 0) return;
|
||||
this.#renderAnnotations(this.#visibleAnnotations, this.#annotationAnchorLine);
|
||||
}
|
||||
|
||||
#clearAnnotations(): void {
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#annotationMarker = null;
|
||||
this.#visibleAnnotations = Object.freeze([]);
|
||||
this.#annotationAnchorLine = null;
|
||||
}
|
||||
|
||||
setActive(active: boolean, terminalBackground?: string): void {
|
||||
if (this.#disposed || this.#active === active) {
|
||||
if (active && terminalBackground) void this.refreshBackground('theme-changed', terminalBackground);
|
||||
@@ -475,7 +506,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#backgroundGeneration += 1;
|
||||
this.#pendingSemantics.splice(0);
|
||||
disposeAll(this.#matcherDecorations);
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#renderBackground([]);
|
||||
}
|
||||
|
||||
@@ -493,7 +524,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#pendingSemantics.splice(0);
|
||||
if (!visible) {
|
||||
disposeAll(this.#matcherDecorations);
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#renderBackground([]);
|
||||
}
|
||||
if (visible && this.#active) {
|
||||
@@ -510,7 +541,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#backgroundGeneration += 1;
|
||||
this.#abortProviderRequests();
|
||||
this.#pendingSemantics.splice(0);
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#clearAnnotations();
|
||||
if (!this.#isProviderAvailable('terminal.background')) this.#renderBackground([]);
|
||||
if (!this.#isProviderAvailable('terminal.matcher')) disposeAll(this.#matcherDecorations);
|
||||
if (this.#active && this.#visible) {
|
||||
@@ -557,7 +588,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#abortProviderRequests();
|
||||
this.#pendingSemantics.splice(0);
|
||||
disposeAll(this.#matcherDecorations);
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#backgroundOverlay?.remove();
|
||||
this.#backgroundOverlay = null;
|
||||
disposeAll(this.#disposables);
|
||||
|
||||
@@ -1797,7 +1797,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
{ replayBacklog: true },
|
||||
);
|
||||
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
|
||||
ctx.updateStatus("disconnected");
|
||||
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
writeTerminalLine(ctx, term, exitMessage);
|
||||
@@ -1810,6 +1810,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
|
||||
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
|
||||
try {
|
||||
await ctx.prepareKeywordHighlightSerialization?.();
|
||||
const terminalData = ctx.serializeAddonRef.current.serialize();
|
||||
logger.info("[Terminal] Serialized terminal data", {
|
||||
sessionId: ctx.sessionId,
|
||||
|
||||
@@ -209,6 +209,7 @@ export type TerminalSessionStartersContext = {
|
||||
disposeTelnetEchoModeRef?: RefObject<(() => void) | null>;
|
||||
fitAddonRef: RefObject<FitAddon | null>;
|
||||
serializeAddonRef: RefObject<SerializeAddon | null>;
|
||||
prepareKeywordHighlightSerialization?: () => Promise<void>;
|
||||
pendingAuthRef: RefObject<PendingAuth>;
|
||||
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
|
||||
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
|
||||
|
||||
@@ -536,7 +536,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
cursorStyle,
|
||||
cursorBlink,
|
||||
scrollback,
|
||||
// Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
|
||||
// Cursor-line rendering and Unicode width handling use proposed APIs.
|
||||
allowProposedApi: true,
|
||||
drawBoldTextInBrightColors,
|
||||
minimumContrastRatio,
|
||||
@@ -2218,7 +2218,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
resizeScheduler.schedule({ sessionId: id, cols, rows });
|
||||
});
|
||||
|
||||
const keywordHighlighter = new KeywordHighlighter(term);
|
||||
const keywordHighlighter = new KeywordHighlighter(term, {
|
||||
serializeAddon,
|
||||
});
|
||||
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
|
||||
|
||||
const cursorLineHighlighter = new CursorLineHighlighter(term);
|
||||
|
||||
@@ -260,7 +260,7 @@ test('CursorLineHighlighter refreshes after hidden writes become visible', () =>
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter keeps a continuous background under keyword decorations', () => {
|
||||
test('CursorLineHighlighter keeps a continuous background under colored keyword text', () => {
|
||||
const term = createFakeTerm(10);
|
||||
const keywordMarker = term.registerMarker(0);
|
||||
term.registerDecoration({
|
||||
@@ -352,7 +352,7 @@ test('CursorLineHighlighter fills a wrap-pending final cell', () => {
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter leaves colored and inverse cells untouched', () => {
|
||||
test('CursorLineHighlighter keeps foreground colors and leaves inverse cells untouched', () => {
|
||||
const term = createFakeTerm(10);
|
||||
term.setColoredForegrounds([2, 3]);
|
||||
term.setInverseCells([7]);
|
||||
@@ -363,8 +363,7 @@ test('CursorLineHighlighter leaves colored and inverse cells untouched', () => {
|
||||
assert.deepEqual(
|
||||
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
|
||||
[
|
||||
{ x: 0, width: 2 },
|
||||
{ x: 4, width: 3 },
|
||||
{ x: 0, width: 7 },
|
||||
{ x: 8, width: 2 },
|
||||
],
|
||||
);
|
||||
|
||||
@@ -23,8 +23,8 @@ type HighlightRange = { x: number; width: number };
|
||||
|
||||
/**
|
||||
* Highlights the buffer row under the cursor without tinting its glyphs.
|
||||
* ANSI-colored cells keep their own background; only default-background cells
|
||||
* receive the opaque theme color.
|
||||
* Explicit ANSI backgrounds remain untouched. Foreground-only colors still
|
||||
* receive the opaque theme background so colored text does not leave holes.
|
||||
*/
|
||||
export class CursorLineHighlighter implements IDisposable {
|
||||
private enabled = false;
|
||||
@@ -211,9 +211,7 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
}
|
||||
const isHighlightable =
|
||||
currentCell === undefined ||
|
||||
(currentCell.isBgDefault() &&
|
||||
currentCell.isFgDefault() &&
|
||||
!currentCell.isInverse());
|
||||
(currentCell.isBgDefault() && !currentCell.isInverse());
|
||||
if (isHighlightable && rangeStart === null) rangeStart = x;
|
||||
if ((!isHighlightable || x === cols - 1) && rangeStart !== null) {
|
||||
const end = isHighlightable && x === cols - 1 ? x + 1 : x;
|
||||
|
||||
@@ -49,7 +49,7 @@ export function resolveHibernateSnapshotCapturePayload(
|
||||
export async function serializeTerminalCloseFallback(
|
||||
term: XTerm,
|
||||
serializeAddon: SerializeAddon,
|
||||
options: { preferWasm?: boolean } = {},
|
||||
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
|
||||
): Promise<TerminalCloseCapturePayload | null> {
|
||||
const snapshot = await serializeTerminalForHibernate(term, serializeAddon, options);
|
||||
return resolveHibernateSnapshotCapturePayload(snapshot);
|
||||
|
||||
@@ -792,6 +792,40 @@ const attachLedgerAnchors = (
|
||||
}
|
||||
};
|
||||
|
||||
/** Reattach sparse anchors after a display-only terminal buffer rebuild. */
|
||||
export const restoreTerminalLineTimestampAnchors = (term: XTerm): void => {
|
||||
const store = getTimestampStore(term);
|
||||
for (const entry of store.ledger) {
|
||||
if (!entry.marker?.isDisposed) continue;
|
||||
entry.marker = undefined;
|
||||
}
|
||||
attachLedgerAnchors(term, store, store.ledger);
|
||||
store.lastSeenBaseY = term.buffer.normal.baseY;
|
||||
store.lastSeenBufferLength = term.buffer.normal.length;
|
||||
store.lastSeenCols = _getTerminalColumnCount(term);
|
||||
notifyTimestampStore(store);
|
||||
};
|
||||
|
||||
export const snapshotTerminalLineTimestampLedger = (term: XTerm): Array<{
|
||||
label: string;
|
||||
secondKey: number;
|
||||
line: number;
|
||||
}> => getTimestampStore(term).ledger.map((entry) => ({
|
||||
label: entry.label,
|
||||
secondKey: entry.secondKey,
|
||||
line: entry.marker && !entry.marker.isDisposed ? entry.marker.line : entry.line,
|
||||
}));
|
||||
|
||||
export const restoreTerminalLineTimestampLedger = (
|
||||
term: XTerm,
|
||||
snapshot: readonly { label: string; secondKey: number; line: number }[],
|
||||
): void => {
|
||||
const store = getTimestampStore(term);
|
||||
store.ledger = snapshot.map((entry) => ({ ...entry }));
|
||||
store.lastStampSecondKey = store.ledger.at(-1)?.secondKey ?? null;
|
||||
restoreTerminalLineTimestampAnchors(term);
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh ledger.line from live sparse anchors. Drops entries whose markers
|
||||
* were disposed by scrollback trim. Bare (unanchored) lines keep their values
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resetTerminalOutputPressure,
|
||||
setTerminalOutputPressureVisibility,
|
||||
shouldDegradeTerminalSideWork,
|
||||
shouldDegradeTerminalKeywordHighlight,
|
||||
shouldSkipTerminalLineTimestamps,
|
||||
} from "./terminalOutputPressure.ts";
|
||||
import { TERMINAL_LONG_LINE_PRESSURE_BYTES } from "./terminalFlowConstants.ts";
|
||||
@@ -172,6 +173,21 @@ test("arms large-output early on multi-line writes when scrollback is saturated"
|
||||
}
|
||||
});
|
||||
|
||||
test("a one-line prompt does not degrade keyword coloring on saturated scrollback", () => {
|
||||
const term = createFakeTerm({
|
||||
rows: 24,
|
||||
options: { scrollback: 1000 },
|
||||
buffer: { active: { length: 1020, baseY: 996 } },
|
||||
});
|
||||
const prompt = "\r\nplain prompt # ";
|
||||
|
||||
noteTerminalOutputPressureData(term, prompt);
|
||||
|
||||
assert.equal(shouldDegradeTerminalSideWork(term), true);
|
||||
assert.equal(shouldDegradeTerminalKeywordHighlight(term, prompt), false);
|
||||
resetTerminalOutputPressure(term);
|
||||
});
|
||||
|
||||
test("does not treat an empty buffer as scrollback-saturated", () => {
|
||||
const term = createFakeTerm({
|
||||
rows: 24,
|
||||
@@ -196,6 +212,10 @@ test("saturated multi-line degrades side work but keeps line timestamps", () =>
|
||||
// but per-line gutter timestamps must still stamp.
|
||||
noteTerminalOutputPressureData(term, "CONTAINER ID IMAGE\n".repeat(20));
|
||||
assert.equal(shouldDegradeTerminalSideWork(term), true);
|
||||
assert.equal(
|
||||
shouldDegradeTerminalKeywordHighlight(term, "CONTAINER ID IMAGE\n".repeat(20)),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldSkipTerminalLineTimestamps(term), false);
|
||||
|
||||
resetTerminalOutputPressure(term);
|
||||
|
||||
@@ -299,6 +299,19 @@ export const shouldDegradeTerminalSideWork = (term: XTerm): boolean => {
|
||||
return pressure.largeOutput || pressure.longLine;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyword coloring only examines the current write, so a stale quiet-window
|
||||
* bulk flag must not make a later one-line prompt schedule a history rebuild.
|
||||
*/
|
||||
export const shouldDegradeTerminalKeywordHighlight = (
|
||||
term: XTerm,
|
||||
data: string,
|
||||
): boolean => {
|
||||
const state = getOrCreateState(term);
|
||||
if (state.longLine || data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES) return true;
|
||||
return state.recentSampleBytes >= LARGE_OUTPUT_RATE_BYTES;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether line-timestamp registerMarker work should be skipped.
|
||||
*
|
||||
|
||||
@@ -2089,7 +2089,7 @@ test("attachSessionToTerminal clears the backend id before reporting exit", () =
|
||||
assert.equal(sessionIdSeenByConsumer, null);
|
||||
});
|
||||
|
||||
test("attachSessionToTerminal drains hidden final output before exit capture", () => {
|
||||
test("attachSessionToTerminal drains hidden final output before exit capture", async () => {
|
||||
const { term, writes } = createFakeTerm();
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
let onExit: ((evt: { reason?: string }) => void) | null = null;
|
||||
@@ -2130,6 +2130,7 @@ test("attachSessionToTerminal drains hidden final output before exit capture", (
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
onExit?.({ reason: "closed" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const expected = "final output\r\n\r\n[session closed]\r\n";
|
||||
assert.equal(writes.join(""), expected);
|
||||
|
||||
@@ -956,7 +956,7 @@ export const attachSessionToTerminal = (
|
||||
);
|
||||
ctx.terminalBackend.notifyTerminalSessionDisplayReady?.(id);
|
||||
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
|
||||
// The backend is already gone. In particular, an observe popup must not
|
||||
// run its normal pause/snapshot/restore handoff while closing afterward.
|
||||
if (ctx.sessionRef.current === id) {
|
||||
@@ -971,6 +971,7 @@ export const attachSessionToTerminal = (
|
||||
|
||||
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
|
||||
try {
|
||||
await ctx.prepareKeywordHighlightSerialization?.();
|
||||
const terminalData = ctx.serializeAddonRef.current.serialize();
|
||||
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
} catch (err) {
|
||||
|
||||
@@ -899,8 +899,7 @@ test("leave-alt CSI clears latch even while buffer still reports alternate", ()
|
||||
// Buffer flips to normal after leave is written; latch must be gone.
|
||||
(term.buffer.active as { type: string }).type = "normal";
|
||||
enqueueCoalescedTerminalWrite(term, "shell after tui", () => {});
|
||||
assert.equal(microtasks.length, 1);
|
||||
assert.equal(frames.length, 0);
|
||||
assert.equal(frames.length, 0, "must not stay on rAF after leave-alt");
|
||||
} finally {
|
||||
resetTerminalWriteCoalescer(term);
|
||||
globalThis.queueMicrotask = originalMicrotask;
|
||||
@@ -956,8 +955,7 @@ test("enter-then-leave in one chunk does not latch rAF forever", () => {
|
||||
microtasks.length = 0;
|
||||
|
||||
enqueueCoalescedTerminalWrite(term, "ordinary shell output", () => {});
|
||||
assert.equal(microtasks.length, 1);
|
||||
assert.equal(frames.length, 0);
|
||||
assert.equal(frames.length, 0, "must not latch rAF after enter-then-leave");
|
||||
} finally {
|
||||
resetTerminalWriteCoalescer(term);
|
||||
globalThis.queueMicrotask = originalMicrotask;
|
||||
|
||||
@@ -670,11 +670,11 @@ export const enqueueCoalescedTerminalWrite = (
|
||||
}, {
|
||||
getMaxPendingBytes: () => resolveCoalescerByteCap(term),
|
||||
shouldFlushScheduledFrame: () => terminalWriteCoalescerFlushGates.get(term)?.() ?? true,
|
||||
// Tabby streams normal-screen output without waiting for vsync. Keep rAF
|
||||
// for alternate-screen TUIs (including the enter-alt burst before xterm
|
||||
// has switched buffer.active.type) so multi-chunk repaints stay atomic.
|
||||
// When rAF is unavailable (Node unit tests), prefer the "raf" mode so the
|
||||
// coalescer falls back to an immediate flush (legacy test contract).
|
||||
// Keep rAF for alternate-screen TUIs (including the enter-alt burst
|
||||
// before xterm has switched buffer.active.type) so multi-chunk
|
||||
// repaints stay atomic. Normal-screen output uses Electerm-style burst
|
||||
// coalescing. When rAF is unavailable (Node unit tests), prefer "raf"
|
||||
// so the coalescer falls back to an immediate flush (legacy contract).
|
||||
resolveScheduleMode: ({ nextChunk }): WriteCoalesceScheduleMode => {
|
||||
const preapplied = preappliedAltScreenNeedsRafByTerm.get(term);
|
||||
if (preapplied !== undefined) {
|
||||
@@ -693,7 +693,9 @@ export const enqueueCoalescedTerminalWrite = (
|
||||
const canUseMicrotask = typeof queueMicrotask === "function"
|
||||
&& typeof globalThis.requestAnimationFrame === "function";
|
||||
if (canUseMicrotask) {
|
||||
return "microtask";
|
||||
// Electerm-style 16ms merge after a recent flush; idle echo still
|
||||
// uses a microtask so typing does not wait a frame.
|
||||
return "burst";
|
||||
}
|
||||
return "raf";
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, test } from "node:test";
|
||||
import {
|
||||
createWriteCoalescer,
|
||||
MAX_PENDING_WRITE_COALESCE_BYTES,
|
||||
WRITE_COALESCE_BURST_MS,
|
||||
type WriteCoalescerOptions,
|
||||
} from "./writeCoalescer.ts";
|
||||
|
||||
@@ -87,6 +88,53 @@ test("microtask schedule mode flushes after the current turn without rAF", async
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
test("burst mode flushes idle output on the next microtask", async () => {
|
||||
const writes: string[] = [];
|
||||
const coalescer = createWriteCoalescer((data) => writes.push(data), {
|
||||
resolveScheduleMode: () => "burst",
|
||||
});
|
||||
|
||||
coalescer.push("a");
|
||||
coalescer.push("b");
|
||||
assert.equal(writes.length, 0);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(writes, ["ab"]);
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
test("burst mode merges writes that arrive inside the 16ms window", () => {
|
||||
const writes: string[] = [];
|
||||
let now = 1000;
|
||||
const timeouts: Array<{ callback: () => void; ms: number }> = [];
|
||||
const coalescer = createWriteCoalescer((data) => writes.push(data), {
|
||||
resolveScheduleMode: () => "burst",
|
||||
now: () => now,
|
||||
burstWindowMs: WRITE_COALESCE_BURST_MS,
|
||||
scheduleTimeout(callback, ms) {
|
||||
timeouts.push({ callback, ms: ms ?? 0 });
|
||||
return () => {
|
||||
const index = timeouts.findIndex((entry) => entry.callback === callback);
|
||||
if (index >= 0) timeouts.splice(index, 1);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.push("first");
|
||||
coalescer.flushSync();
|
||||
assert.deepEqual(writes, ["first"]);
|
||||
|
||||
now += 4;
|
||||
coalescer.push("second");
|
||||
coalescer.push("third");
|
||||
assert.deepEqual(writes, ["first"]);
|
||||
assert.equal(timeouts.length, 1);
|
||||
assert.ok(timeouts[0]!.ms <= WRITE_COALESCE_BURST_MS);
|
||||
|
||||
timeouts[0]!.callback();
|
||||
assert.deepEqual(writes, ["first", "secondthird"]);
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
test("upgrades a pending microtask schedule to rAF when a later chunk requests it", () => {
|
||||
const writes: string[] = [];
|
||||
const microtasks: Array<() => void> = [];
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* per chunk, which can tear TUI frames (missing box borders, clipped bottom
|
||||
* rows). Batching keeps rendering atomic per schedule turn.
|
||||
*
|
||||
* Schedule modes (Tabby-inspired):
|
||||
* Schedule modes (Tabby + Electerm-inspired):
|
||||
* - `raf`: wait for the next animation frame (best for alternate-screen TUIs)
|
||||
* - `microtask`: flush after the current JS turn (normal-screen bulk / echo —
|
||||
* closer to Tabby's direct write, still coalesces same-turn chunks)
|
||||
* - `microtask`: flush after the current JS turn (same-turn merge only)
|
||||
* - `burst`: idle output still flushes next microtask; writes that arrive
|
||||
* inside a 16ms window after the last flush wait and merge (Electerm's
|
||||
* attach-addon flood path). Never drops bytes.
|
||||
*
|
||||
* Ported from superset-sh/superset (issues #2241 / #2244):
|
||||
* apps/desktop/src/renderer/lib/terminal/write-coalescer.ts
|
||||
@@ -27,6 +29,11 @@ export {
|
||||
|
||||
/** Maximum time pending output may wait for its primary scheduler. */
|
||||
export const WRITE_COALESCE_FALLBACK_DRAIN_MS = 50;
|
||||
/**
|
||||
* Electerm-style burst window. After a flush, further chunks wait up to this
|
||||
* long so a log flood becomes ~60 writes/s instead of one write per IPC turn.
|
||||
*/
|
||||
export const WRITE_COALESCE_BURST_MS = 16;
|
||||
|
||||
export type WriteCoalescer = {
|
||||
push(chunk: string): void;
|
||||
@@ -38,7 +45,7 @@ export type WriteCoalescer = {
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export type WriteCoalesceScheduleMode = "raf" | "microtask";
|
||||
export type WriteCoalesceScheduleMode = "raf" | "microtask" | "burst";
|
||||
|
||||
export type WriteCoalesceScheduleContext = {
|
||||
/** Chunk about to be (or just) enqueued. */
|
||||
@@ -63,6 +70,8 @@ export type WriteCoalescerOptions = {
|
||||
resolveScheduleMode?: (ctx: WriteCoalesceScheduleContext) => WriteCoalesceScheduleMode;
|
||||
getMaxPendingBytes?: () => number;
|
||||
shouldFlushScheduledFrame?: () => boolean;
|
||||
now?: () => number;
|
||||
burstWindowMs?: number;
|
||||
};
|
||||
|
||||
const scheduleRafFrame = (callback: () => void): (() => void) | null => {
|
||||
@@ -149,6 +158,7 @@ export const createWriteCoalescer = (
|
||||
let cancelPendingSchedule: (() => void) | null = null;
|
||||
let scheduledMode: WriteCoalesceScheduleMode | null = null;
|
||||
let disposed = false;
|
||||
let lastFlushTime = 0;
|
||||
const customScheduleFrame = options.scheduleFrame;
|
||||
const scheduleTimeout = options.scheduleTimeout ?? defaultScheduleTimeout;
|
||||
const fallbackDrainMs = options.fallbackDrainMs ?? WRITE_COALESCE_FALLBACK_DRAIN_MS;
|
||||
@@ -156,6 +166,12 @@ export const createWriteCoalescer = (
|
||||
const getMaxPendingBytes = options.getMaxPendingBytes
|
||||
?? (() => MAX_PENDING_WRITE_COALESCE_BYTES);
|
||||
const shouldFlushScheduledFrame = options.shouldFlushScheduledFrame ?? (() => true);
|
||||
const now = options.now ?? (() => (
|
||||
typeof performance !== "undefined" && typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
));
|
||||
const burstWindowMs = options.burstWindowMs ?? WRITE_COALESCE_BURST_MS;
|
||||
|
||||
const cancelScheduledDrain = (): void => {
|
||||
if (cancelPendingSchedule !== null) {
|
||||
@@ -208,6 +224,23 @@ export const createWriteCoalescer = (
|
||||
flushSync();
|
||||
};
|
||||
|
||||
if (mode === "burst") {
|
||||
const elapsed = lastFlushTime === 0 ? burstWindowMs : now() - lastFlushTime;
|
||||
if (lastFlushTime > 0 && elapsed < burstWindowMs) {
|
||||
const waitMs = Math.max(1, burstWindowMs - elapsed);
|
||||
const cancel = scheduleTimeout(onScheduled, waitMs);
|
||||
if (cancel === null) {
|
||||
if (!shouldFlushScheduledFrame()) return;
|
||||
flushSync();
|
||||
return;
|
||||
}
|
||||
cancelPendingSchedule = cancel;
|
||||
scheduledMode = "burst";
|
||||
return;
|
||||
}
|
||||
mode = "microtask";
|
||||
}
|
||||
|
||||
const primaryCancel = scheduleByMode(mode, onScheduled, customScheduleFrame);
|
||||
const fallbackCancel = mode === "raf"
|
||||
&& fallbackDrainMs > 0
|
||||
@@ -234,6 +267,7 @@ export const createWriteCoalescer = (
|
||||
const batch = pending.length === 1 ? pending[0]! : pending.join("");
|
||||
pending = [];
|
||||
pendingBytes = 0;
|
||||
lastFlushTime = now();
|
||||
(writeOverride ?? write)(batch);
|
||||
};
|
||||
|
||||
@@ -245,6 +279,7 @@ export const createWriteCoalescer = (
|
||||
const dropped = pendingBytes;
|
||||
pending = [];
|
||||
pendingBytes = 0;
|
||||
lastFlushTime = 0;
|
||||
onDropped?.(dropped);
|
||||
};
|
||||
|
||||
@@ -275,7 +310,7 @@ export const createWriteCoalescer = (
|
||||
// Upgrade microtask → rAF when a later chunk enters a TUI: the first shell
|
||||
// chunk may have scheduled a same-turn flush before the alt-screen CSI
|
||||
// arrived, which would tear the first repaint (Codex PR review).
|
||||
if (scheduledMode === "microtask" && mode === "raf") {
|
||||
if ((scheduledMode === "microtask" || scheduledMode === "burst") && mode === "raf") {
|
||||
cancelScheduledDrain();
|
||||
armSchedule("raf");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
formatDiskCapacityGb,
|
||||
formatDiskCapacityRange,
|
||||
resolveTerminalDiskSummary,
|
||||
} from "./serverStatsFormat.ts";
|
||||
|
||||
@@ -14,6 +15,32 @@ test("disk capacity uses at most two decimal places", () => {
|
||||
assert.equal(formatDiskCapacityGb(10.5), "10.5");
|
||||
});
|
||||
|
||||
test("disk capacity stays in G below a terabyte", () => {
|
||||
assert.equal(formatDiskCapacityRange(71.12, 878.15), "71.12/878.15G");
|
||||
assert.equal(formatDiskCapacityRange(0.01, 0.5), "0.01/0.5G");
|
||||
// 1023 GiB is still the largest value rendered in G.
|
||||
assert.equal(formatDiskCapacityRange(1, 1023), "1/1023G");
|
||||
});
|
||||
|
||||
test("disk capacity switches to T once a value reaches a terabyte", () => {
|
||||
// Both sides in terabytes: one shared suffix.
|
||||
assert.equal(formatDiskCapacityRange(8807.55, 118736.4), "8.6/115.95T");
|
||||
assert.equal(formatDiskCapacityRange(2048, 4096), "2/4T");
|
||||
});
|
||||
|
||||
test("disk capacity keeps the used figure when the sides differ in scale", () => {
|
||||
// A total-driven unit would render these as 0.13/6.93T and 0/6.93T,
|
||||
// discarding how much is actually in use.
|
||||
assert.equal(formatDiskCapacityRange(136.37, 7096.47), "136.37G/6.93T");
|
||||
assert.equal(formatDiskCapacityRange(3.25, 7096.47), "3.25G/6.93T");
|
||||
assert.equal(formatDiskCapacityRange(1, 1024), "1G/1T");
|
||||
});
|
||||
|
||||
test("disk capacity switches to P for petabyte-scale pools", () => {
|
||||
assert.equal(formatDiskCapacityRange(1_048_576, 2_097_152), "1/2P");
|
||||
assert.equal(formatDiskCapacityRange(512, 2_097_152), "512G/2P");
|
||||
});
|
||||
|
||||
test("terminal disk summary totals every mounted filesystem", () => {
|
||||
const summary = resolveTerminalDiskSummary({
|
||||
diskUsed: 9.285,
|
||||
|
||||
@@ -20,6 +20,42 @@ export function formatDiskCapacityGb(value: number): string {
|
||||
return Number(value.toFixed(2)).toString();
|
||||
}
|
||||
|
||||
/** Capacity units, ascending from the GiB the collectors report in. */
|
||||
const DISK_CAPACITY_UNITS = ["G", "T", "P"] as const;
|
||||
|
||||
/** Pick the largest unit that keeps a GiB value at or above 1. */
|
||||
export function resolveDiskCapacityUnit(valueGb: number): { unit: string; divisor: number } {
|
||||
let divisor = 1;
|
||||
let index = 0;
|
||||
while (
|
||||
Math.abs(valueGb) / divisor >= 1024
|
||||
&& index < DISK_CAPACITY_UNITS.length - 1
|
||||
) {
|
||||
divisor *= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return { unit: DISK_CAPACITY_UNITS[index], divisor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a used/total pair, both reported in GiB.
|
||||
*
|
||||
* Each side is scaled independently so a mostly-empty multi-terabyte pool keeps
|
||||
* its used figure — `136.37G/6.93T` rather than the `0.13/6.93T` a total-driven
|
||||
* unit would produce, or the unreadable `136.37/7096.47G` of a fixed one. When
|
||||
* both land in the same unit the suffix is written once, keeping the familiar
|
||||
* `71.12/878.15G` shape for ordinary disks.
|
||||
*/
|
||||
export function formatDiskCapacityRange(usedGb: number, totalGb: number): string {
|
||||
const used = resolveDiskCapacityUnit(usedGb);
|
||||
const total = resolveDiskCapacityUnit(totalGb);
|
||||
const usedText = formatDiskCapacityGb(usedGb / used.divisor);
|
||||
const totalText = formatDiskCapacityGb(totalGb / total.divisor);
|
||||
return used.unit === total.unit
|
||||
? `${usedText}/${totalText}${total.unit}`
|
||||
: `${usedText}${used.unit}/${totalText}${total.unit}`;
|
||||
}
|
||||
|
||||
export function resolveTerminalDiskSummary(
|
||||
stats: TerminalDiskStats,
|
||||
): TerminalDiskSummary {
|
||||
|
||||
@@ -42,6 +42,57 @@ test("filters by host targets when set", () => {
|
||||
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-2" }).map((o) => o.snippet?.id).sort(), ["g", "t"]);
|
||||
});
|
||||
|
||||
test("filters dynamic group targets using nested host group membership", () => {
|
||||
const scoped = snip({
|
||||
id: "grouped",
|
||||
label: "deploy-group",
|
||||
targetGroups: ["Production"],
|
||||
});
|
||||
const mixed = snip({
|
||||
id: "mixed",
|
||||
label: "deploy-mixed",
|
||||
targets: ["host-explicit"],
|
||||
targetGroups: ["Staging"],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
getSnippetSuggestions("deploy", [scoped, mixed], {
|
||||
hostId: "host-prod",
|
||||
hostGroup: "Production/Web",
|
||||
}).map((item) => item.snippet?.id),
|
||||
["grouped"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getSnippetSuggestions("deploy", [scoped, mixed], {
|
||||
hostId: "host-staging",
|
||||
hostGroup: "Staging/API",
|
||||
}).map((item) => item.snippet?.id),
|
||||
["mixed"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getSnippetSuggestions("deploy", [scoped, mixed], {
|
||||
hostId: "host-dev",
|
||||
hostGroup: "Development",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("does not surface a snippet with an explicitly empty group scope", () => {
|
||||
const disabled = snip({
|
||||
id: "disabled-group-scope",
|
||||
label: "deploy-disabled",
|
||||
targetGroups: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
getSnippetSuggestions("deploy", [disabled], {
|
||||
hostId: "host-prod",
|
||||
hostGroup: "Production",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("no match returns empty; empty input returns empty", () => {
|
||||
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
|
||||
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
|
||||
|
||||
@@ -164,7 +164,7 @@ test("reconnect preparation cancels an in-flight disconnected hibernate", () =>
|
||||
const claimIndex = reconnectBody.indexOf(
|
||||
"reconnectPreparationTokenRef.current = retryToken",
|
||||
);
|
||||
const cleanupIndex = reconnectBody.indexOf("await cleanupSession()");
|
||||
const cleanupIndex = reconnectBody.indexOf("await cleanupSession({ retainOwnership: true })");
|
||||
const startNewSessionIndex = reconnectBody.indexOf("const startNewSession = () =>");
|
||||
|
||||
assert.ok(claimIndex >= 0 && claimIndex < cleanupIndex);
|
||||
|
||||
@@ -321,3 +321,22 @@ test("serializeTerminalForHibernate preserves plain text context for normal scre
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
test("serializeTerminalForHibernate waits for deferred history preparation", async () => {
|
||||
const term = createFakeTerm("normal", 3, 5);
|
||||
const events: string[] = [];
|
||||
const result = await serializeTerminalForHibernate(term as never, {
|
||||
serialize: () => {
|
||||
events.push("serialize");
|
||||
return "serialized";
|
||||
},
|
||||
} as never, {
|
||||
prepare: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
events.push("prepare");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.snapshot, "serialized");
|
||||
assert.deepEqual(events.slice(0, 2), ["prepare", "serialize"]);
|
||||
});
|
||||
@@ -189,8 +189,9 @@ async function serializeWithOptions(
|
||||
export async function serializeTerminalForHibernate(
|
||||
term: XTerm,
|
||||
serializeAddon: SerializeAddon,
|
||||
options: { preferWasm?: boolean } = {},
|
||||
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
|
||||
): Promise<TerminalHibernateSnapshot> {
|
||||
await options.prepare?.();
|
||||
const { excludeAltBuffer, excludeModes, alternateScreen } = resolveHibernateSerializeOptions(term);
|
||||
const preferWasm = options.preferWasm === true;
|
||||
const rows = Math.max(1, term.rows);
|
||||
|
||||
@@ -909,7 +909,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
if (!terminalDataCapturedRef.current && term && serializeAddon) {
|
||||
const preferWasm = resolveHibernatePreferWasmSerialize(terminalSettingsRef.current);
|
||||
try {
|
||||
const payload = await serializeTerminalCloseFallback(term, serializeAddon, { preferWasm });
|
||||
const payload = await serializeTerminalCloseFallback(term, serializeAddon, {
|
||||
preferWasm,
|
||||
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
|
||||
?? Promise.resolve(),
|
||||
});
|
||||
if (!isTerminalCloseGenerationCurrent(
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
VaultTreeGroupRow,
|
||||
@@ -118,3 +119,45 @@ test("VaultTreeInlineRenameInput uses shared inline edit marker", () => {
|
||||
assert.match(markup, /data-vault-tree-inline-edit="true"/);
|
||||
assert.match(markup, /value="Ops"/);
|
||||
});
|
||||
|
||||
test("VaultTreeInlineRenameInput can retry after an asynchronous commit failure", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<VaultTreeInlineRenameInput
|
||||
initialName="Ops"
|
||||
onCommit={async () => {
|
||||
attempts += 1;
|
||||
return attempts > 1;
|
||||
}}
|
||||
onCancel={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const input = renderer!.root.findByType("input");
|
||||
const pressEnter = async () => {
|
||||
input.props.onKeyDown({
|
||||
key: "Enter",
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
};
|
||||
await act(pressEnter);
|
||||
await act(pressEnter);
|
||||
assert.equal(attempts, 2);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { cn } from "../../lib/utils";
|
||||
|
||||
type VaultTreeInlineRenameInputProps = {
|
||||
initialName: string;
|
||||
onCommit: (name: string) => void;
|
||||
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -20,7 +20,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialName);
|
||||
const committedRef = useRef(false);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
@@ -29,15 +29,20 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(value);
|
||||
const commit = async () => {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
try {
|
||||
const committed = await onCommit(value);
|
||||
if (committed === false) submittingRef.current = false;
|
||||
} catch {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@@ -51,7 +56,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
void commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -66,7 +71,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -91,7 +96,7 @@ type VaultTreeGroupRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childr
|
||||
hasChildren?: boolean;
|
||||
editing?: boolean;
|
||||
editingInitialName?: string;
|
||||
onRenameCommit?: (name: string) => void;
|
||||
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onRenameCancel?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
labelActions?: React.ReactNode;
|
||||
@@ -190,7 +195,7 @@ type VaultTreeItemRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childre
|
||||
actions?: React.ReactNode;
|
||||
editing?: boolean;
|
||||
editingInitialName?: string;
|
||||
onRenameCommit?: (name: string) => void;
|
||||
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onRenameCancel?: () => void;
|
||||
content?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -402,7 +402,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
[treeViewGroupTree],
|
||||
);
|
||||
const visibleSelectableGroupPaths = React.useMemo(
|
||||
() => new globalThis.Set(
|
||||
() => new globalThis.Set<string>(
|
||||
viewMode === "tree"
|
||||
? visibleTreeGroupPaths
|
||||
: displayedGroups.map((group: { path: string }) => group.path),
|
||||
@@ -1685,11 +1685,16 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
onClick={async () => {
|
||||
if (pendingDeleteGroupPaths.length > 0) {
|
||||
const isBulkDelete = bulkDeleteGroupPaths.length > 0;
|
||||
await deleteGroupPaths(
|
||||
pendingDeleteGroupPaths,
|
||||
deleteGroupWithHosts,
|
||||
isBulkDelete ? selectedHostIds : new Set(),
|
||||
);
|
||||
try {
|
||||
await deleteGroupPaths(
|
||||
pendingDeleteGroupPaths,
|
||||
deleteGroupWithHosts,
|
||||
isBulkDelete ? selectedHostIds : new Set<string>(),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("common.error"));
|
||||
return;
|
||||
}
|
||||
if (isBulkDelete) {
|
||||
const deletedItemCount =
|
||||
selectedHostIds.size + selectedGroupPaths.size;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { hostTreeInlineGroupEditStore } from '../../application/state/hostTreeInlineGroupEditStore';
|
||||
import { useHostTreeInlineGroupActions } from './useHostTreeInlineGroupActions';
|
||||
|
||||
test('inline group rename stays open when the Vault commit is superseded', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let startRename: ((groupPath: string) => void) | undefined;
|
||||
let commitRename: ((name: string) => Promise<boolean>) | undefined;
|
||||
const selectedPaths: Array<string | null> = [];
|
||||
|
||||
const Probe = () => {
|
||||
const actions = useHostTreeInlineGroupActions({
|
||||
customGroups: ['prod'],
|
||||
hosts: [],
|
||||
managedSources: [],
|
||||
onUpdateCustomGroups: () => undefined,
|
||||
onCommitGroupPathChange: async () => ({ ok: false, superseded: true }),
|
||||
selectedGroupPath: 'prod',
|
||||
setSelectedGroupPath: (path) => selectedPaths.push(path),
|
||||
ensurePathExpanded: () => undefined,
|
||||
unnamedGroupLabel: 'New group',
|
||||
t: (key) => key,
|
||||
});
|
||||
startRename = actions.startInlineRenameGroup;
|
||||
commitRename = actions.commitInlineGroupRename;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
act(() => {
|
||||
startRename?.('prod');
|
||||
});
|
||||
await act(async () => {
|
||||
await commitRename?.('production');
|
||||
});
|
||||
|
||||
assert.deepEqual(selectedPaths, []);
|
||||
assert.equal(hostTreeInlineGroupEditStore.getEdit()?.groupPath, 'prod');
|
||||
} finally {
|
||||
hostTreeInlineGroupEditStore.clear();
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -17,8 +17,13 @@ type UseHostTreeInlineGroupActionsParams = {
|
||||
hosts: Host[];
|
||||
managedSources: ManagedSource[];
|
||||
onUpdateCustomGroups: (groups: string[]) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateManagedSources: (sources: ManagedSource[]) => void;
|
||||
onCommitGroupPathChange: (
|
||||
sourcePath: string,
|
||||
nextPath: string,
|
||||
) => Promise<
|
||||
| { ok: true }
|
||||
| { ok: false; error?: string; superseded?: true }
|
||||
>;
|
||||
selectedGroupPath: string | null;
|
||||
setSelectedGroupPath: (path: string | null) => void;
|
||||
ensurePathExpanded: (path: string) => void;
|
||||
@@ -31,8 +36,7 @@ export function useHostTreeInlineGroupActions({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
onCommitGroupPathChange,
|
||||
selectedGroupPath,
|
||||
setSelectedGroupPath,
|
||||
ensurePathExpanded,
|
||||
@@ -73,9 +77,9 @@ export function useHostTreeInlineGroupActions({
|
||||
hostTreeInlineGroupEditStore.clear();
|
||||
}, [customGroups, onUpdateCustomGroups]);
|
||||
|
||||
const commitInlineGroupRename = useCallback((rawName: string) => {
|
||||
const commitInlineGroupRename = useCallback(async (rawName: string): Promise<boolean> => {
|
||||
const edit = hostTreeInlineGroupEditStore.getEdit();
|
||||
if (!edit) return;
|
||||
if (!edit) return false;
|
||||
|
||||
const result = applyGroupPathRename({
|
||||
renameTargetPath: edit.groupPath,
|
||||
@@ -88,32 +92,32 @@ export function useHostTreeInlineGroupActions({
|
||||
if (result.ok === false) {
|
||||
if (result.error === 'unchanged') {
|
||||
hostTreeInlineGroupEditStore.clear();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (result.error === 'required') {
|
||||
if (edit.isNew) {
|
||||
cancelInlineGroupEdit();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
toast.error(t('vault.groups.errors.required'));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (result.error === 'invalidChars') {
|
||||
toast.error(t('vault.groups.errors.invalidChars'));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (result.error === 'duplicatePath') {
|
||||
toast.error(t('vault.groups.errors.duplicatePath'));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.updatedManagedSources.some((source, index) => source !== managedSources[index])) {
|
||||
onUpdateManagedSources(result.updatedManagedSources);
|
||||
const committed = await onCommitGroupPathChange(edit.groupPath, result.nextPath);
|
||||
if (!committed.ok) {
|
||||
toast.error(committed.error || t('common.error'));
|
||||
return false;
|
||||
}
|
||||
onUpdateCustomGroups(result.updatedGroups);
|
||||
onUpdateHosts(result.updatedHosts);
|
||||
|
||||
if (
|
||||
selectedGroupPath
|
||||
@@ -127,14 +131,13 @@ export function useHostTreeInlineGroupActions({
|
||||
}
|
||||
|
||||
hostTreeInlineGroupEditStore.clear();
|
||||
return true;
|
||||
}, [
|
||||
cancelInlineGroupEdit,
|
||||
customGroups,
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
onCommitGroupPathChange,
|
||||
selectedGroupPath,
|
||||
setSelectedGroupPath,
|
||||
t,
|
||||
|
||||
@@ -23,7 +23,7 @@ type RegisterVaultHostTreeActionsParams = {
|
||||
startInlineNewGroup: (parentPath?: string) => void;
|
||||
startInlineRenameGroup: (groupPath: string) => void;
|
||||
startInlineDeleteGroup: (groupPath: string) => void;
|
||||
commitInlineGroupRename: (name: string) => void;
|
||||
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit: () => void;
|
||||
commitInlineHostRename: (name: string) => void;
|
||||
cancelInlineHostEdit: () => void;
|
||||
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user