[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
57
application/AppHandlers.closeTabsBatch.test.ts
Normal file
57
application/AppHandlers.closeTabsBatch.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeTabsBatchImpl } from './app/AppHandlers.ts';
|
||||
|
||||
test('batch tab close removes standalone sessions in one state update', async () => {
|
||||
const closedSessionBatches: string[][] = [];
|
||||
const probedSessionBatches: string[][] = [];
|
||||
const closeTabsInFlightRef = { current: false };
|
||||
const sessions = [
|
||||
{ id: 's1', protocol: 'ssh' },
|
||||
{ id: 's2', protocol: 'ssh' },
|
||||
{ id: 's3', protocol: 'ssh' },
|
||||
];
|
||||
|
||||
const result = await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => {},
|
||||
closeSessions: (sessionIds: string[]) => closedSessionBatches.push(sessionIds),
|
||||
closeTabsInFlightRef,
|
||||
closeWorkspace: () => {},
|
||||
confirmIfBusyLocalTerminal: async (sessionIds: string[]) => {
|
||||
probedSessionBatches.push(sessionIds);
|
||||
return true;
|
||||
},
|
||||
logViews: [],
|
||||
sessions,
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1', 's2', 's3'],
|
||||
);
|
||||
|
||||
assert.deepEqual(probedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.deepEqual(closedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.equal(closeTabsInFlightRef.current, false);
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test('batch tab close reports cancellation before mutating any tab', async () => {
|
||||
let mutated = false;
|
||||
const result = await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => { mutated = true; },
|
||||
closeSessions: () => { mutated = true; },
|
||||
closeTabsInFlightRef: { current: false },
|
||||
closeWorkspace: () => { mutated = true; },
|
||||
confirmIfBusyLocalTerminal: async () => false,
|
||||
logViews: [],
|
||||
sessions: [{ id: 's1', protocol: 'local' }],
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1'],
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(mutated, false);
|
||||
});
|
||||
400
application/AppHandlers.connect.test.ts
Normal file
400
application/AppHandlers.connect.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
flushQueuedTrayPanelConnectHostsImpl,
|
||||
handleConnectToHostImpl,
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
id: 'host-1',
|
||||
label: '10.2.0.32',
|
||||
hostname: '10.2.0.32',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
protocol: 'ssh',
|
||||
};
|
||||
|
||||
test('connect host handler returns the created terminal tab id', () => {
|
||||
const logs: unknown[] = [];
|
||||
const connectedHosts: Host[] = [];
|
||||
const result = handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: (entry: unknown) => logs.push(entry),
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'session-from-connect';
|
||||
},
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: 'root' }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
}),
|
||||
baseHost,
|
||||
);
|
||||
|
||||
assert.equal(result, 'session-from-connect');
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(logs.length, 1);
|
||||
});
|
||||
|
||||
test('connect logs use the same Mosh-before-ET protocol precedence as the launcher', () => {
|
||||
const logs: Array<{ protocol?: string }> = [];
|
||||
handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: (entry: { protocol?: string }) => logs.push(entry),
|
||||
connectToHost: () => 'session-both-transports',
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: 'root' }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
}),
|
||||
{ ...baseHost, moshEnabled: true, etEnabled: true },
|
||||
);
|
||||
|
||||
assert.equal(logs[0]?.protocol, 'mosh');
|
||||
});
|
||||
|
||||
test('connect serial host handler returns the created terminal tab id', () => {
|
||||
const serialHost: Host = {
|
||||
...baseHost,
|
||||
id: 'serial-1',
|
||||
label: '',
|
||||
hostname: '/dev/tty.usbserial',
|
||||
protocol: 'serial',
|
||||
};
|
||||
|
||||
const result = handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: () => 'serial-session',
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: 'root' }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
}),
|
||||
serialHost,
|
||||
);
|
||||
|
||||
assert.equal(result, 'serial-session');
|
||||
});
|
||||
|
||||
test('tray panel connect request queues until the vault is initialized', () => {
|
||||
const queuedHostIds: string[] = [];
|
||||
const connectedHostIds: string[] = [];
|
||||
|
||||
handleTrayPanelConnectRequestImpl(
|
||||
() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
isVaultInitialized: false,
|
||||
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
|
||||
}),
|
||||
'host-1',
|
||||
);
|
||||
|
||||
assert.deepEqual(queuedHostIds, ['host-1']);
|
||||
assert.deepEqual(connectedHostIds, []);
|
||||
});
|
||||
|
||||
test('tray panel connect request runs immediately after the vault is initialized', () => {
|
||||
const queuedHostIds: string[] = [];
|
||||
const connectedHostIds: string[] = [];
|
||||
|
||||
handleTrayPanelConnectRequestImpl(
|
||||
() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
isVaultInitialized: true,
|
||||
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
|
||||
}),
|
||||
'host-1',
|
||||
);
|
||||
|
||||
assert.deepEqual(queuedHostIds, []);
|
||||
assert.deepEqual(connectedHostIds, ['host-1']);
|
||||
});
|
||||
|
||||
test('queued tray panel connects flush in order', () => {
|
||||
const connectedHostIds: string[] = [];
|
||||
let pendingHostIds = ['host-1', 'host-2'];
|
||||
|
||||
flushQueuedTrayPanelConnectHostsImpl(() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
pendingHostIds,
|
||||
setPendingHostIds: (nextHostIds: string[]) => {
|
||||
pendingHostIds = nextHostIds;
|
||||
},
|
||||
}));
|
||||
|
||||
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
|
||||
assert.deepEqual(pendingHostIds, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit can save login password for the session host', async () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
password: 'old-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-1',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
const bridgeResponses: unknown[] = [];
|
||||
const hostUpdates: Host[][] = [];
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: (...args: unknown[]) => {
|
||||
bridgeResponses.push(args);
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hostUpdates.push(nextHosts);
|
||||
hosts = nextHosts;
|
||||
},
|
||||
}),
|
||||
'ki-1',
|
||||
['login-password', 'otp-code'],
|
||||
'new-login-password',
|
||||
);
|
||||
|
||||
assert.equal(hostUpdates.length, 1);
|
||||
assert.deepEqual(hosts[0], {
|
||||
...baseHost,
|
||||
password: 'new-login-password',
|
||||
savePassword: true,
|
||||
});
|
||||
assert.deepEqual(queue, []);
|
||||
assert.equal(bridgeResponses.length, 1);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
password: 'login-password',
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-external',
|
||||
sessionId: 'sftp-connection-1',
|
||||
hostId: baseHost.id,
|
||||
scope: 'external',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: false,
|
||||
}];
|
||||
let hostUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hostUpdates += 1;
|
||||
hosts = nextHosts;
|
||||
},
|
||||
}),
|
||||
'ki-external',
|
||||
['secondary-password'],
|
||||
'should-not-save',
|
||||
);
|
||||
|
||||
assert.equal(hostUpdates, 0);
|
||||
assert.equal(hosts[0].password, 'login-password');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit uses explicit hostId when saving password', async () => {
|
||||
const jumpHost: Host = {
|
||||
...baseHost,
|
||||
id: 'jump-1',
|
||||
label: 'Jump',
|
||||
hostname: 'jump.example.com',
|
||||
password: 'old-jump-password',
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
password: 'target-password',
|
||||
}, jumpHost];
|
||||
let queue = [{
|
||||
requestId: 'ki-jump',
|
||||
sessionId: 'terminal-session-1',
|
||||
hostId: jumpHost.id,
|
||||
scope: 'terminal',
|
||||
hostname: jumpHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'terminal-session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
}),
|
||||
'ki-jump',
|
||||
['jump-login-password'],
|
||||
'new-jump-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts.find((host) => host.id === baseHost.id)?.password, 'target-password');
|
||||
assert.equal(hosts.find((host) => host.id === jumpHost.id)?.password, 'new-jump-password');
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit preserves host changes made while delivery is pending', async () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
label: 'Original label',
|
||||
password: 'old-password',
|
||||
}];
|
||||
const hostsRef = { current: hosts };
|
||||
let queue = [{
|
||||
requestId: 'ki-delayed',
|
||||
sessionId: 'session-delayed',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let resolveDelivery: (result: { success: boolean }) => void = () => {};
|
||||
const delivery = new Promise<{ success: boolean }>((resolve) => {
|
||||
resolveDelivery = resolve;
|
||||
});
|
||||
|
||||
const submitPromise = handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
hostsRef,
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({ respondKeyboardInteractive: () => delivery }),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-delayed',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
hostsRef.current = nextHosts;
|
||||
},
|
||||
}),
|
||||
'ki-delayed',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
hosts = [{ ...hosts[0], label: 'Synced label', tags: ['synced'] }];
|
||||
hostsRef.current = hosts;
|
||||
resolveDelivery({ success: true });
|
||||
await submitPromise;
|
||||
|
||||
assert.deepEqual(hosts[0], {
|
||||
...baseHost,
|
||||
label: 'Synced label',
|
||||
tags: ['synced'],
|
||||
password: 'new-password',
|
||||
savePassword: true,
|
||||
});
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit keeps the prompt and password unchanged when delivery fails', async () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
password: 'old-password',
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-failed',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
const errors: string[] = [];
|
||||
|
||||
const submitted = await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: false, error: 'Request not found' }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: (message: string) => errors.push(message) },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
}),
|
||||
'ki-failed',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(submitted, false);
|
||||
assert.deepEqual(queue.map((request) => request.requestId), ['ki-failed']);
|
||||
assert.equal(hosts[0].password, 'old-password');
|
||||
assert.deepEqual(errors, ['Request not found']);
|
||||
});
|
||||
700
application/AppHandlers.globalHotkeys.test.ts
Normal file
700
application/AppHandlers.globalHotkeys.test.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
executeHotkeyActionImpl,
|
||||
getLogHostVisualSnapshot,
|
||||
handleEscapeKeyDownImpl,
|
||||
handleGlobalHotkeyKeyDownImpl,
|
||||
markForwardedNativeShortcutEvent,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
class FakeInputHTMLElement {
|
||||
tagName = 'INPUT';
|
||||
isContentEditable = false;
|
||||
|
||||
closest(): FakeInputHTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
isContentEditable = false;
|
||||
classList = {
|
||||
contains: (className: string) => className === 'xterm-helper-textarea',
|
||||
};
|
||||
|
||||
closest(selector: string): FakeHTMLElement | null {
|
||||
return selector.includes('xterm') ? this : null;
|
||||
}
|
||||
|
||||
hasAttribute(name: string): boolean {
|
||||
return name === 'data-session-id';
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMonacoHTMLElement extends FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
|
||||
closest(selector: string): FakeMonacoHTMLElement | null {
|
||||
return selector.includes('monaco') ? this : null;
|
||||
}
|
||||
}
|
||||
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
|
||||
|
||||
test.after(() => {
|
||||
globalThis.HTMLElement = previousHTMLElement;
|
||||
});
|
||||
|
||||
test('global hotkey handler lets terminal font size shortcuts reach xterm', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const event = {
|
||||
key: '=',
|
||||
code: 'Equal',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
stopPropagation: () => {
|
||||
stopped = true;
|
||||
},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, []);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('global hotkey handler routes quick switch through focused search inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['quickSwitch']);
|
||||
});
|
||||
|
||||
test('global hotkey handler magnifies panes from focused form inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const event = {
|
||||
key: 'm',
|
||||
code: 'KeyM',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['togglePaneZoom']);
|
||||
});
|
||||
|
||||
test('forwarded native shortcut can run a reassigned global action from Monaco', () => {
|
||||
const target = new FakeMonacoHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
let prevented = false;
|
||||
const event = markForwardedNativeShortcutEvent({
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
const keyBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
|
||||
if (binding.action === 'closeTab') return { ...binding, mac: 'Disabled' };
|
||||
if (binding.action === 'newTab') return { ...binding, mac: '⌘ + W' };
|
||||
return binding;
|
||||
});
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Disabled',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'mac',
|
||||
keyBindings,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['newTab']);
|
||||
assert.equal(prevented, true);
|
||||
});
|
||||
|
||||
test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
let isQuickSwitcherOpen = false;
|
||||
const setIsQuickSwitcherOpen = (next: boolean) => {
|
||||
isQuickSwitcherOpen = next;
|
||||
};
|
||||
const noop = () => {};
|
||||
const baseCtx = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent;
|
||||
|
||||
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, true);
|
||||
|
||||
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
test('pane zoom hotkey delegates to the active in-app magnification surface', () => {
|
||||
let toggles = 0;
|
||||
const noop = () => {};
|
||||
const controller = {
|
||||
getState: () => 'focusable' as const,
|
||||
focus: () => false,
|
||||
restore: () => false,
|
||||
toggle: () => {
|
||||
toggles += 1;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'workspace-1' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
sftpPaneMagnificationRef: { current: null },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
terminalPaneMagnificationRef: { current: controller },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
}), 'togglePaneZoom', {} as KeyboardEvent);
|
||||
|
||||
assert.equal(toggles, 1);
|
||||
});
|
||||
|
||||
test('broadcast hotkey toggles global mode for an active orphan tab', () => {
|
||||
let globalToggles = 0;
|
||||
let workspaceToggles = 0;
|
||||
|
||||
executeHotkeyActionImpl(() => ({
|
||||
activeTabStore: { getActiveTabId: () => 'orphan-1' },
|
||||
editorTabs: [],
|
||||
orderedTabs: ['orphan-1', 'orphan-2'],
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
toEditorTabId: (id: string) => id,
|
||||
sessions: [
|
||||
{ id: 'orphan-1' },
|
||||
{ id: 'orphan-2' },
|
||||
],
|
||||
workspaces: [],
|
||||
canUseGlobalBroadcast: true,
|
||||
toggleBroadcast: () => { workspaceToggles += 1; },
|
||||
toggleGlobalBroadcast: () => { globalToggles += 1; },
|
||||
}), 'broadcast', {} as KeyboardEvent);
|
||||
|
||||
assert.equal(globalToggles, 1);
|
||||
assert.equal(workspaceToggles, 0);
|
||||
});
|
||||
|
||||
test('move-focus shortcut cannot send input behind a magnified pane', () => {
|
||||
let moveCalls = 0;
|
||||
executeHotkeyActionImpl(() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'workspace-1' },
|
||||
editorTabs: [],
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: () => {
|
||||
moveCalls += 1;
|
||||
return true;
|
||||
},
|
||||
orderedTabs: [],
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
sftpPaneMagnificationRef: { current: null },
|
||||
terminalPaneMagnificationRef: {
|
||||
current: {
|
||||
getState: () => 'focused' as const,
|
||||
focus: () => false,
|
||||
restore: () => true,
|
||||
toggle: () => true,
|
||||
},
|
||||
},
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
workspaces: [{ id: 'workspace-1', title: 'Workspace' }],
|
||||
}), 'moveFocus', {
|
||||
key: 'ArrowRight',
|
||||
} as KeyboardEvent);
|
||||
|
||||
assert.equal(moveCalls, 0);
|
||||
});
|
||||
|
||||
test('Escape restores magnification after transient dialogs are closed', () => {
|
||||
let restores = 0;
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const event = {
|
||||
key: 'Escape',
|
||||
defaultPrevented: false,
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleEscapeKeyDownImpl(() => ({
|
||||
isQuickSwitcherOpen: false,
|
||||
setIsQuickSwitcherOpen: () => {},
|
||||
sftpPaneMagnificationRef: { current: null },
|
||||
terminalPaneMagnificationRef: {
|
||||
current: {
|
||||
getState: () => 'focused',
|
||||
focus: () => false,
|
||||
restore: () => {
|
||||
restores += 1;
|
||||
return true;
|
||||
},
|
||||
toggle: () => false,
|
||||
},
|
||||
},
|
||||
}), event);
|
||||
|
||||
assert.equal(restores, 1);
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(stopped, true);
|
||||
});
|
||||
|
||||
test('consumed Escape does not restore magnification', () => {
|
||||
let restores = 0;
|
||||
handleEscapeKeyDownImpl(() => ({
|
||||
isQuickSwitcherOpen: false,
|
||||
setIsQuickSwitcherOpen: () => {},
|
||||
terminalPaneMagnificationRef: {
|
||||
current: {
|
||||
getState: () => 'focused',
|
||||
focus: () => false,
|
||||
restore: () => {
|
||||
restores += 1;
|
||||
return true;
|
||||
},
|
||||
toggle: () => false,
|
||||
},
|
||||
},
|
||||
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
|
||||
|
||||
assert.equal(restores, 0);
|
||||
});
|
||||
|
||||
test('close tab hotkey routes native plugin view tabs through their owner', () => {
|
||||
let closedTabId = '';
|
||||
const pluginTabId = 'plugin-view:com.example.view:com.example.view.panel';
|
||||
const noop = () => {};
|
||||
|
||||
executeHotkeyActionImpl(() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => pluginTabId },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closePluginViewTab: (tabId: string) => { closedTabId = tabId; },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isPluginViewTabId: (tabId: string) => tabId.startsWith('plugin-view:'),
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [pluginTabId],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
}), 'closeTab', { key: 'w', metaKey: true } as KeyboardEvent);
|
||||
|
||||
assert.equal(closedTabId, pluginTabId);
|
||||
});
|
||||
|
||||
test('next, previous, and number shortcuts include native plugin view tabs', () => {
|
||||
const pluginTabId = 'plugin-view:com.example.view:com.example.view.panel';
|
||||
let activeTabId = 'session-1';
|
||||
const selected: string[] = [];
|
||||
const noop = () => {};
|
||||
const context = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closePluginViewTab: noop,
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isPluginViewTabId: (tabId: string) => tabId.startsWith('plugin-view:'),
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1', pluginTabId, 'session-2'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; selected.push(id); },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(() => context, 'nextTab', { key: 'Tab', ctrlKey: true } as KeyboardEvent);
|
||||
assert.equal(activeTabId, pluginTabId);
|
||||
executeHotkeyActionImpl(() => context, 'prevTab', { key: 'Tab', ctrlKey: true, shiftKey: true } as KeyboardEvent);
|
||||
assert.equal(activeTabId, 'session-1');
|
||||
executeHotkeyActionImpl(() => context, 'switchToTab', { key: '3', metaKey: true } as KeyboardEvent);
|
||||
assert.equal(activeTabId, pluginTabId);
|
||||
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
|
||||
});
|
||||
|
||||
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
|
||||
let activeTabId = 'session-1';
|
||||
const noop = () => {};
|
||||
const context = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1', 'session-2', 'session-3'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => context,
|
||||
'switchToTab',
|
||||
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
assert.equal(activeTabId, 'session-2');
|
||||
});
|
||||
|
||||
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
|
||||
let activeTabId = '';
|
||||
const noop = () => {};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [{ id: 'editor-1' }],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
}),
|
||||
'nextTab',
|
||||
{ key: 'Tab', ctrlKey: true } as KeyboardEvent,
|
||||
);
|
||||
|
||||
assert.equal(activeTabId, 'sftp');
|
||||
});
|
||||
|
||||
test('next tab skips pinned tabs when shell-only shortcut mode is enabled', () => {
|
||||
let activeTabId = '';
|
||||
const noop = () => {};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [{ id: 'editor-1' }],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: true },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
}),
|
||||
'nextTab',
|
||||
{ key: 'Tab', ctrlKey: true } as KeyboardEvent,
|
||||
);
|
||||
|
||||
assert.equal(activeTabId, 'session-1');
|
||||
});
|
||||
|
||||
test('connection log host snapshot includes custom host icon fields', () => {
|
||||
assert.deepEqual(
|
||||
getLogHostVisualSnapshot({
|
||||
id: 'host-1',
|
||||
label: 'Database',
|
||||
hostname: 'db.example.com',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
distro: 'ubuntu',
|
||||
iconMode: 'custom',
|
||||
iconId: 'database',
|
||||
iconColor: 'blue',
|
||||
}),
|
||||
{
|
||||
hostOs: 'linux',
|
||||
hostDistro: 'ubuntu',
|
||||
hostIconMode: 'custom',
|
||||
hostIconId: 'database',
|
||||
hostIconColorMode: 'manual',
|
||||
hostIconColor: 'blue',
|
||||
},
|
||||
);
|
||||
});
|
||||
189
application/AppHandlers.newWindow.test.ts
Normal file
189
application/AppHandlers.newWindow.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { TerminalSession } from "../domain/models";
|
||||
import { copySessionToNewWindowWithCurrentShellImpl } from "./app/AppHandlers";
|
||||
|
||||
const sourceSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Prod SSH",
|
||||
hostname: "prod.example.com",
|
||||
username: "deploy",
|
||||
status: "connected",
|
||||
protocol: "ssh",
|
||||
port: 22,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer window for the selected session", async () => {
|
||||
const openedPayloads: unknown[] = [];
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async (payload: unknown) => {
|
||||
openedPayloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(openedPayloads.length, 1);
|
||||
assert.deepEqual(openedPayloads[0], {
|
||||
title: "Prod SSH",
|
||||
sourceSession: sourceSession(),
|
||||
localShellType: "zsh",
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl preserves local start directory in the source session", async () => {
|
||||
const openedPayloads: unknown[] = [];
|
||||
const localSession = sourceSession({
|
||||
hostLabel: "Local Terminal",
|
||||
hostname: "localhost",
|
||||
protocol: "local",
|
||||
localStartDir: "/Users/alice/project with spaces ",
|
||||
});
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async (payload: unknown) => {
|
||||
openedPayloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [localSession],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(openedPayloads.length, 1);
|
||||
assert.deepEqual(openedPayloads[0], {
|
||||
title: "Local Terminal",
|
||||
sourceSession: localSession,
|
||||
localShellType: "zsh",
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
|
||||
let called = false;
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => {
|
||||
called = true;
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"missing-session",
|
||||
);
|
||||
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when Electron cannot open the window", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => ({ success: false }),
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge is unavailable", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge throws", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
130
application/AppHandlers.trayJump.test.ts
Normal file
130
application/AppHandlers.trayJump.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { TerminalSession } from "../domain/models";
|
||||
import {
|
||||
buildAiSilentSessionPopupPayload,
|
||||
handleTrayJumpToSessionImpl,
|
||||
} from "./app/AppHandlers";
|
||||
|
||||
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "AI Box",
|
||||
hostname: "10.0.0.1",
|
||||
username: "root",
|
||||
status: "connected",
|
||||
protocol: "ssh",
|
||||
port: 22,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("buildAiSilentSessionPopupPayload attaches the same live session PTY", () => {
|
||||
const payload = buildAiSilentSessionPopupPayload(
|
||||
session({ hiddenFromTabs: true }),
|
||||
);
|
||||
|
||||
assert.equal(payload.parentSessionId, "session-1");
|
||||
assert.equal(payload.attachSessionId, "session-1");
|
||||
assert.equal(payload.title, "AI Box");
|
||||
assert.equal(payload.startupCommand, "");
|
||||
assert.equal(payload.sourceSession.hiddenFromTabs, undefined);
|
||||
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, undefined);
|
||||
});
|
||||
|
||||
test("handleTrayJumpToSessionImpl opens a terminal popup for AI silent sessions", async () => {
|
||||
const opened: unknown[] = [];
|
||||
let activeTabId = "session-1";
|
||||
|
||||
await handleTrayJumpToSessionImpl(
|
||||
() => ({
|
||||
sessions: [session({ hiddenFromTabs: true })],
|
||||
setActiveTabId: (id: string) => {
|
||||
activeTabId = id;
|
||||
},
|
||||
getActiveTabId: () => activeTabId,
|
||||
setWorkspaceFocusedSession: () => {
|
||||
throw new Error("should not focus workspace for silent sessions");
|
||||
},
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openTerminalPopup: async (payload: unknown) => {
|
||||
opened.push(payload);
|
||||
return { success: true, popupId: "popup-1" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(opened.length, 1);
|
||||
assert.equal(activeTabId, "vault");
|
||||
assert.deepEqual(opened[0], buildAiSilentSessionPopupPayload(session({ hiddenFromTabs: true })));
|
||||
});
|
||||
|
||||
test("handleTrayJumpToSessionImpl still activates normal solo sessions in the main window", async () => {
|
||||
let activeTabId = "vault";
|
||||
let openedMain = 0;
|
||||
|
||||
await handleTrayJumpToSessionImpl(
|
||||
() => ({
|
||||
sessions: [session()],
|
||||
setActiveTabId: (id: string) => {
|
||||
activeTabId = id;
|
||||
},
|
||||
setWorkspaceFocusedSession: () => {
|
||||
throw new Error("solo sessions should not use workspace focus");
|
||||
},
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openMainWindow: async () => {
|
||||
openedMain += 1;
|
||||
return { success: true };
|
||||
},
|
||||
openTerminalPopup: async () => {
|
||||
throw new Error("should not open popup for visible sessions");
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(activeTabId, "session-1");
|
||||
assert.equal(openedMain, 1);
|
||||
});
|
||||
|
||||
test("handleTrayJumpToSessionImpl focuses workspace sessions without opening a popup", async () => {
|
||||
let activeTabId = "vault";
|
||||
let focused: { workspaceId: string; sessionId: string } | null = null;
|
||||
let openedMain = 0;
|
||||
|
||||
await handleTrayJumpToSessionImpl(
|
||||
() => ({
|
||||
sessions: [session({ workspaceId: "ws-1" })],
|
||||
setActiveTabId: (id: string) => {
|
||||
activeTabId = id;
|
||||
},
|
||||
setWorkspaceFocusedSession: (workspaceId: string, sessionId: string) => {
|
||||
focused = { workspaceId, sessionId };
|
||||
},
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openMainWindow: async () => {
|
||||
openedMain += 1;
|
||||
return { success: true };
|
||||
},
|
||||
openTerminalPopup: async () => {
|
||||
throw new Error("should not open popup for workspace sessions");
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(activeTabId, "ws-1");
|
||||
assert.equal(openedMain, 1);
|
||||
assert.deepEqual(focused, { workspaceId: "ws-1", sessionId: "session-1" });
|
||||
});
|
||||
160
application/app/AppActiveTabChrome.tsx
Normal file
160
application/app/AppActiveTabChrome.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
useActiveTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
||||
import type {
|
||||
Host,
|
||||
TerminalSession,
|
||||
TerminalTheme,
|
||||
Workspace,
|
||||
} from '../../types';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
|
||||
export interface AppActiveTabChromeProps {
|
||||
showSftpTab: boolean;
|
||||
setActiveTabId: (id: string) => void;
|
||||
applyAppTheme: () => void;
|
||||
hostById: Map<string, Host>;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme: boolean;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
logViews: readonly LogView[];
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
|
||||
* work derived from it: window title and the SFTP-tab guard.
|
||||
* Extracted out of <App> so that switching top tabs only
|
||||
* re-renders this null-rendering component (and the self-subscribing leaves)
|
||||
* instead of forcing the entire App tree (which holds all vault/session/
|
||||
* settings state and rebuilds the giant AppView ctx) to re-render.
|
||||
*
|
||||
* Accent comes from appearanceChromeStore so color-picker drag does not
|
||||
* rebuild AppShell chrome props.
|
||||
*/
|
||||
export function AppActiveTabChrome({
|
||||
showSftpTab,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
currentTerminalTheme,
|
||||
followAppTerminalTheme,
|
||||
editorTabs,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
t,
|
||||
}: AppActiveTabChromeProps) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSftpTab && activeTabId === 'sftp') {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
const chromeThemeDeps = useMemo(() => ({
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}), [
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
updateActiveChromeThemeDeps(chromeThemeDeps);
|
||||
|
||||
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
|
||||
...chromeThemeDeps,
|
||||
activeTabId,
|
||||
}), [chromeThemeDeps, activeTabId]);
|
||||
|
||||
useActiveChromeTheme({
|
||||
activeTheme: activeChromeTheme,
|
||||
applyAppTheme,
|
||||
});
|
||||
|
||||
const editorTabFileNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [editorTabs]);
|
||||
|
||||
const activeWindowTitle = useMemo(() => {
|
||||
if (activeTabId === 'vault') return 'Vaults';
|
||||
if (activeTabId === 'sftp') return 'SFTP';
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
|
||||
if (!editorTab) return 'Editor';
|
||||
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
|
||||
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
|
||||
: '';
|
||||
return `${editorTab.fileName}${suffix}`;
|
||||
}
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) return workspace.title;
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (session) return session.hostLabel;
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
|
||||
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
|
||||
}
|
||||
return 'Netcatty';
|
||||
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
|
||||
|
||||
useEffect(() => {
|
||||
// Title is already memoized by activeTabId; skip redundant IPC when the
|
||||
// string did not change (e.g. two tabs sharing the same host label).
|
||||
let cancelled = false;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setWindowTitle) return;
|
||||
// Defer slightly so the title write does not compete with tab-switch paint.
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!cancelled) void bridge.setWindowTitle?.(activeWindowTitle);
|
||||
}, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [activeWindowTitle]);
|
||||
|
||||
return null;
|
||||
}
|
||||
57
application/app/AppFollowTerminalTheme.test.ts
Normal file
57
application/app/AppFollowTerminalTheme.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const appSideEffectsSource = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
const terminalHostSource = readFileSync(new URL("./hosts/TerminalHost.tsx", import.meta.url), "utf8");
|
||||
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
|
||||
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
|
||||
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
|
||||
|
||||
test("follow-app terminal theme selection updates the matching UI theme via ThemeRuntime", () => {
|
||||
assert.match(runtimeSource, /getFollowAppTerminalThemeSelectionUpdate\(themeId\)/);
|
||||
assert.match(runtimeSource, /setDarkUiThemeId\(update\.uiThemeId\)/);
|
||||
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
|
||||
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
|
||||
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
|
||||
assert.match(terminalHostSource, /useThemeRuntime\(/);
|
||||
assert.match(terminalHostSource, /pickTerminalTheme\(themeId\)/);
|
||||
assert.match(terminalHostSource, /pickTheme: pickTerminalTheme/);
|
||||
// Theme members are listed field-by-field on the TerminalHost bag (not themeRuntime bag).
|
||||
assert.match(terminalHostSource, /clearThemeIntent,/);
|
||||
assert.match(terminalHostSource, /settleManualThemeIntent,/);
|
||||
assert.match(terminalHostSource, /pickTerminalTheme,/);
|
||||
assert.match(terminalHostSource, /resolveSessionAppearance: resolveFocusedAppearance/);
|
||||
assert.doesNotMatch(
|
||||
terminalHostSource,
|
||||
/followAppTerminalTheme, themeRuntime, handleConnectSerial/,
|
||||
);
|
||||
// Terminal domain must not thrash on whole settings bag identity.
|
||||
assert.match(terminalHostSource, /sshDebugLogsEnabled:/);
|
||||
assert.doesNotMatch(
|
||||
terminalHostSource,
|
||||
/splitSessionWithCurrentShell, settings, terminalFontFamilyId/,
|
||||
);
|
||||
// Hotkey path must not depend on whole settings/sessions for callback identity.
|
||||
assert.match(appSideEffectsSource, /showSftpTab: showSftpTabRef\.current/);
|
||||
assert.match(appSideEffectsSource, /sessions: sessionsRef\.current/);
|
||||
assert.match(appSideEffectsSource, /connectionLogs: connectionLogsRef\.current/);
|
||||
assert.match(terminalHostSource, /useTerminalAppearanceInjection/);
|
||||
assert.match(terminalHostSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
||||
assert.match(terminalHostSource, /useTerminalAppearanceInjection\(accentedGlobalAppearance/);
|
||||
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
|
||||
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
|
||||
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
|
||||
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
|
||||
assert.match(settingsSource, /appearanceTransitionModeRef\.current = 'instant'/);
|
||||
assert.match(appViewSource, /data-terminal-appearance-root/);
|
||||
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
|
||||
});
|
||||
|
||||
test("default terminal theme selection persists via TerminalHost", () => {
|
||||
// Product path lives on TerminalHost; AppSideEffects no longer owns this handler.
|
||||
assert.match(terminalHostSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
|
||||
assert.match(terminalHostSource, /setTerminalThemeId\(themeId\)/);
|
||||
assert.match(terminalHostSource, /TERMINAL_THEME_AUTO/);
|
||||
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
|
||||
});
|
||||
48
application/app/AppHandlers.portForwarding.test.ts
Normal file
48
application/app/AppHandlers.portForwarding.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { handleTrayTogglePortForwardImpl } from './AppHandlers';
|
||||
|
||||
const rule = { id: 'rule-1', hostId: 'host-1' };
|
||||
const host = { id: 'host-1' };
|
||||
|
||||
function createContext(options: { requestedStart: boolean; hasRuntimeTunnel: boolean }) {
|
||||
const calls = { start: 0, stop: 0 };
|
||||
const context = {
|
||||
hasRuntimeTunnel: () => options.hasRuntimeTunnel,
|
||||
hosts: [host],
|
||||
identities: [],
|
||||
keys: [],
|
||||
knownHosts: [],
|
||||
portForwardingRules: [rule],
|
||||
resolveEffectiveHost: (value: unknown) => value,
|
||||
startTunnel: () => {
|
||||
calls.start += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
stopTunnel: () => {
|
||||
calls.stop += 1;
|
||||
return Promise.resolve({ success: true });
|
||||
},
|
||||
t: (key: string) => key,
|
||||
terminalSettings: {},
|
||||
toast: { error: () => undefined },
|
||||
};
|
||||
|
||||
handleTrayTogglePortForwardImpl(() => context, rule.id, options.requestedStart);
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('tray ignores a stale start request when the tunnel is already running', () => {
|
||||
const calls = createContext({ requestedStart: true, hasRuntimeTunnel: true });
|
||||
assert.deepEqual(calls, { start: 0, stop: 0 });
|
||||
});
|
||||
|
||||
test('tray starts an inactive rule when no runtime tunnel exists', () => {
|
||||
const calls = createContext({ requestedStart: true, hasRuntimeTunnel: false });
|
||||
assert.deepEqual(calls, { start: 1, stop: 0 });
|
||||
});
|
||||
|
||||
test('tray stop requests remain idempotent', () => {
|
||||
const calls = createContext({ requestedStart: false, hasRuntimeTunnel: false });
|
||||
assert.deepEqual(calls, { start: 0, stop: 1 });
|
||||
});
|
||||
237
application/app/AppHandlers.test.ts
Normal file
237
application/app/AppHandlers.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, splitSessionWithCurrentShellImpl } from "./AppHandlers";
|
||||
import { createCopiedTerminalSessionClone } from "../state/terminalConnectionReuse";
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
type CloneOpts = { localShellType?: string; inheritedCwd?: string; reuseConnection?: boolean };
|
||||
type Calls = {
|
||||
copy?: { id: string; opts: CloneOpts };
|
||||
split?: { id: string; dir: string; opts: CloneOpts };
|
||||
probed: boolean;
|
||||
};
|
||||
|
||||
function ctxFactory(overrides: Record<string, unknown>) {
|
||||
const calls: Calls = { probed: false };
|
||||
const base = {
|
||||
classifyLocalShellType: () => "posix",
|
||||
discoveredShells: [],
|
||||
resolveShellSetting: () => ({ command: "/bin/bash", args: [] }),
|
||||
terminalSettings: { localShell: "bash" },
|
||||
sessions: [{ id: "src", protocol: "ssh", status: "connected", lastCwd: "/var/log" }],
|
||||
// hostById is a Map of saved hosts in the real App — the impl must use
|
||||
// .get(), not call it as a function.
|
||||
hostById: new Map<string, { id: string; distro?: string; deviceType?: string }>(),
|
||||
terminalHosts: [] as Array<{ id: string; distro?: string; deviceType?: string }>,
|
||||
getSessionRestoreCwd: () => undefined,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
getSessionPwd: async () => { calls.probed = true; return { success: true, cwd: "/live/probed" }; },
|
||||
getSessionRemoteInfo: async () => ({ success: true, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
}),
|
||||
},
|
||||
copySession: (id: string, opts: CloneOpts) => { calls.copy = { id, opts }; },
|
||||
splitSession: (id: string, dir: string, opts: CloneOpts) => { calls.split = { id, dir, opts }; },
|
||||
...overrides,
|
||||
};
|
||||
return { getCtx: () => base, calls };
|
||||
}
|
||||
|
||||
test("copySessionWithCurrentShell does not throw when hostById is a Map and probes live cwd", async () => {
|
||||
const { getCtx, calls } = ctxFactory({});
|
||||
await copySessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, "/live/probed");
|
||||
assert.equal(calls.probed, true);
|
||||
});
|
||||
|
||||
test("splitSessionWithCurrentShell passes inheritedCwd", async () => {
|
||||
const { getCtx, calls } = ctxFactory({});
|
||||
await splitSessionWithCurrentShellImpl(getCtx, "src", "horizontal");
|
||||
assert.equal(calls.split?.opts.inheritedCwd, "/live/probed");
|
||||
});
|
||||
|
||||
test("live tracked cwd is preferred over the probe", async () => {
|
||||
const { getCtx, calls } = ctxFactory({ getSessionRestoreCwd: () => "/live/tracked" });
|
||||
await copySessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, "/live/tracked");
|
||||
assert.equal(calls.probed, false, "must not probe when live cwd is known");
|
||||
});
|
||||
|
||||
for (const protocol of ["ssh", undefined] as const) {
|
||||
for (const liveCwd of ["/srv/old-target", undefined]) {
|
||||
test(`duplicate SSH session does not capture or inject the old target directory (${protocol}, ${liveCwd})`, async () => {
|
||||
const source: TerminalSession = {
|
||||
id: "src", hostId: "bastion", hostLabel: "Bastion", hostname: "bastion.test",
|
||||
username: "alice", protocol, status: "connected", lastCwd: "/saved/old-target",
|
||||
};
|
||||
let cwdReads = 0;
|
||||
let bridgeReads = 0;
|
||||
const { getCtx, calls } = ctxFactory({
|
||||
sessions: [source],
|
||||
getSessionRestoreCwd: () => { cwdReads += 1; return liveCwd; },
|
||||
netcattyBridge: { get: () => { bridgeReads += 1; return {}; } },
|
||||
});
|
||||
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.copy?.id, "src");
|
||||
assert.equal(calls.copy?.opts.reuseConnection, false);
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, undefined);
|
||||
assert.equal(cwdReads, 0, "fresh remote login must not read the previous target's directory");
|
||||
assert.equal(bridgeReads, 0, "fresh remote login must not probe the previous target");
|
||||
const clone = createCopiedTerminalSessionClone(source, {
|
||||
id: "duplicate",
|
||||
inheritedCwd: calls.copy?.opts.inheritedCwd,
|
||||
reuseConnection: calls.copy?.opts.reuseConnection,
|
||||
});
|
||||
assert.equal(clone.requireFreshConnection, true);
|
||||
assert.equal(clone.pendingInitialCwd, undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("duplicate local session retains the current working directory", async () => {
|
||||
const { getCtx, calls } = ctxFactory({
|
||||
sessions: [{ id: "src", protocol: "local", status: "connected", localStartDir: "/home/alice" }],
|
||||
getSessionRestoreCwd: () => "/home/alice/project",
|
||||
});
|
||||
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, "/home/alice/project");
|
||||
assert.equal(calls.probed, false);
|
||||
});
|
||||
|
||||
test("network device (by deviceType) is never probed", async () => {
|
||||
const { getCtx, calls } = ctxFactory({
|
||||
hostById: new Map([["h1", { id: "h1", deviceType: "network" }]]),
|
||||
sessions: [{ id: "src", hostId: "h1", protocol: "ssh", status: "connected", lastCwd: "/vrp" }],
|
||||
});
|
||||
await copySessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.probed, false, "must not open a probe channel on a network device");
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, "/vrp");
|
||||
});
|
||||
|
||||
test("local sessions do not query remote SSH metadata", async () => {
|
||||
let remoteInfoCalls = 0;
|
||||
const { getCtx } = ctxFactory({
|
||||
sessions: [{ id: "src", protocol: "local", status: "connected", localStartDir: "/tmp" }],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
getSessionPwd: async () => ({ success: false }),
|
||||
getSessionRemoteInfo: async () => { remoteInfoCalls += 1; return { success: true }; },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await copySessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(remoteInfoCalls, 0);
|
||||
});
|
||||
|
||||
test("network device detected via distro (ignores cosmetic override) is never probed", async () => {
|
||||
const { getCtx, calls } = ctxFactory({
|
||||
hostById: new Map([["h1", { id: "h1", distro: "huawei" }]]),
|
||||
sessions: [{ id: "src", hostId: "h1", protocol: "ssh", status: "connected", lastCwd: "/vrp" }],
|
||||
});
|
||||
await copySessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.probed, false);
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, "/vrp");
|
||||
});
|
||||
|
||||
test("ephemeral network host (only in terminalHosts) is never probed", async () => {
|
||||
const { getCtx, calls } = ctxFactory({
|
||||
hostById: new Map(),
|
||||
terminalHosts: [{ id: "eph", deviceType: "network" }],
|
||||
sessions: [{ id: "src", hostId: "eph", protocol: "ssh", status: "connected", lastCwd: "/vrp" }],
|
||||
});
|
||||
await copySessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.probed, false);
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, "/vrp");
|
||||
});
|
||||
|
||||
type WorkspaceNode =
|
||||
| { id: string; type: "pane"; sessionId: string }
|
||||
| { id: string; type: "split"; direction: string; children: WorkspaceNode[] };
|
||||
type CopyWorkspaceOpts = { localShellType?: string; perPaneCwd?: Record<string, string | undefined> };
|
||||
|
||||
test("copyWorkspaceWithCurrentShell captures per-pane cwd and copies the workspace", async () => {
|
||||
const calls: { copy?: { id: string; opts: CopyWorkspaceOpts } } = {};
|
||||
const sessions = [
|
||||
{ id: "p1", protocol: "local", localStartDir: "/home/a" },
|
||||
{ id: "p2", protocol: "local", localStartDir: "/home/b" },
|
||||
];
|
||||
const workspaces = [{
|
||||
id: "ws-1",
|
||||
root: {
|
||||
id: "sp", type: "split", direction: "vertical",
|
||||
children: [
|
||||
{ id: "n1", type: "pane", sessionId: "p1" },
|
||||
{ id: "n2", type: "pane", sessionId: "p2" },
|
||||
],
|
||||
} as WorkspaceNode,
|
||||
}];
|
||||
const collectIds = (node: WorkspaceNode): string[] =>
|
||||
node.type === "pane" ? [node.sessionId] : node.children.flatMap(collectIds);
|
||||
const getCtx = () => ({
|
||||
classifyLocalShellType: () => "bash",
|
||||
collectSessionIds: collectIds,
|
||||
copyWorkspace: (id: string, opts: CopyWorkspaceOpts) => { calls.copy = { id, opts }; },
|
||||
discoveredShells: [],
|
||||
getSessionRestoreCwd: () => undefined,
|
||||
hostById: new Map(),
|
||||
terminalHosts: [],
|
||||
netcattyBridge: { get: () => ({}) },
|
||||
resolveShellSetting: () => ({ command: "bash" }),
|
||||
sessions,
|
||||
terminalSettings: { localShell: "bash" },
|
||||
workspaces,
|
||||
});
|
||||
|
||||
await copyWorkspaceWithCurrentShellImpl(getCtx, "ws-1");
|
||||
|
||||
assert.equal(calls.copy?.id, "ws-1");
|
||||
assert.deepEqual(calls.copy?.opts.perPaneCwd, { p1: "/home/a", p2: "/home/b" });
|
||||
assert.equal(calls.copy?.opts.localShellType, "bash");
|
||||
});
|
||||
|
||||
test("copyWorkspaceWithCurrentShell no-ops when the workspace is gone", async () => {
|
||||
let called = false;
|
||||
const getCtx = () => ({
|
||||
classifyLocalShellType: () => "bash",
|
||||
collectSessionIds: () => [],
|
||||
copyWorkspace: () => { called = true; },
|
||||
discoveredShells: [],
|
||||
getSessionRestoreCwd: () => undefined,
|
||||
hostById: new Map(),
|
||||
terminalHosts: [],
|
||||
netcattyBridge: { get: () => ({}) },
|
||||
resolveShellSetting: () => ({ command: "bash" }),
|
||||
sessions: [],
|
||||
terminalSettings: { localShell: "bash" },
|
||||
workspaces: [],
|
||||
});
|
||||
await copyWorkspaceWithCurrentShellImpl(getCtx, "missing");
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("copyWorkspaceWithCurrentShell no-ops when the workspace closes during cwd capture", async () => {
|
||||
let called = false;
|
||||
let workspaces: Array<{ id: string; root: WorkspaceNode }> = [{
|
||||
id: "ws-1",
|
||||
root: { id: "p", type: "pane", sessionId: "local" },
|
||||
}];
|
||||
const getCtx = () => ({
|
||||
classifyLocalShellType: () => "bash",
|
||||
collectSessionIds: () => ["local"],
|
||||
copyWorkspace: () => { called = true; },
|
||||
discoveredShells: [],
|
||||
getSessionRestoreCwd: () => undefined,
|
||||
hostById: new Map(),
|
||||
terminalHosts: [],
|
||||
netcattyBridge: { get: () => ({}) },
|
||||
resolveShellSetting: () => ({ command: "bash" }),
|
||||
sessions: [{ id: "local", protocol: "local", status: "connected", localStartDir: "/tmp" }],
|
||||
terminalSettings: { localShell: "bash" },
|
||||
workspaces,
|
||||
});
|
||||
|
||||
const pending = copyWorkspaceWithCurrentShellImpl(getCtx, "ws-1");
|
||||
workspaces = [];
|
||||
await pending;
|
||||
assert.equal(called, false);
|
||||
});
|
||||
1198
application/app/AppHandlers.ts
Normal file
1198
application/app/AppHandlers.ts
Normal file
File diff suppressed because it is too large
Load Diff
112
application/app/AppHostEditorLayer.test.ts
Normal file
112
application/app/AppHostEditorLayer.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { GroupConfig, Host } from '../../types';
|
||||
import {
|
||||
collectWorkSurfaceHostGroups,
|
||||
collectWorkSurfaceHostTags,
|
||||
getAppHostEditorLayerStyle,
|
||||
resolveWorkSurfaceHostEditorKind,
|
||||
} from './AppHostEditorLayer';
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: 'host-1',
|
||||
label: 'web',
|
||||
hostname: '10.0.0.1',
|
||||
username: 'root',
|
||||
port: 22,
|
||||
protocol: 'ssh',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('serial targets use the serial editor', () => {
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({
|
||||
mode: 'edit',
|
||||
openedHost: host({ protocol: 'serial' }),
|
||||
requestId: 1,
|
||||
}),
|
||||
'serial',
|
||||
);
|
||||
});
|
||||
|
||||
test('new and ssh targets use the standard editor', () => {
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({ mode: 'new', defaultGroup: null, requestId: 1 }),
|
||||
'standard',
|
||||
);
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({ mode: 'edit', openedHost: host(), requestId: 2 }),
|
||||
'standard',
|
||||
);
|
||||
});
|
||||
|
||||
test('editor collections include configured, saved, custom, and ancestor groups', () => {
|
||||
assert.deepEqual(
|
||||
collectWorkSurfaceHostGroups(
|
||||
[host({ group: 'prod/web' })],
|
||||
['manual'],
|
||||
[{ path: 'prod' } as GroupConfig],
|
||||
),
|
||||
['manual', 'prod', 'prod/web'],
|
||||
);
|
||||
});
|
||||
|
||||
test('editor tags are unique and sorted', () => {
|
||||
assert.deepEqual(
|
||||
collectWorkSurfaceHostTags([
|
||||
host({ tags: ['prod', 'blue'] }),
|
||||
host({ id: 'host-2', tags: ['blue'] }),
|
||||
]),
|
||||
['blue', 'prod'],
|
||||
);
|
||||
});
|
||||
|
||||
test('editor overlay leaves the work surface interactive outside the panel', () => {
|
||||
const source = readFileSync(new URL('./AppHostEditorLayer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /pointer-events-none absolute inset-0 z-40/);
|
||||
assert.match(source, /\[&>\*\]:pointer-events-auto/);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('editor host panels share vault resize width persistence', () => {
|
||||
const source = readFileSync(new URL('./AppHostEditorLayer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /STORAGE_KEY_VAULT_HOST_PANEL_WIDTH/);
|
||||
assert.match(source, /resizable:\s*true/);
|
||||
assert.match(source, /\{\.\.\.hostPanelResizeProps\}/);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('editor stays mounted while another app surface is active', () => {
|
||||
assert.deepEqual(getAppHostEditorLayerStyle(false), {
|
||||
display: 'none',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
assert.deepEqual(getAppHostEditorLayerStyle(true), {
|
||||
display: undefined,
|
||||
pointerEvents: undefined,
|
||||
});
|
||||
|
||||
const source = readFileSync(new URL('./AppHostEditorLayer.tsx', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /!surfaceVisible\) return null/);
|
||||
assert.match(source, /style=\{getAppHostEditorLayerStyle\(surfaceVisible\)\}/);
|
||||
assert.match(source, /ref=\{setPortalContainer\}/);
|
||||
assert.match(source, /<PortalContainerProvider container=\{portalContainer\}>/);
|
||||
});
|
||||
|
||||
test('AppView composes host-tree actions with the work-surface editor', () => {
|
||||
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /useWorkSurfaceHostEditor/);
|
||||
assert.match(source, /<AppHostEditorLayer/);
|
||||
assert.match(source, /onNewHost=\{workSurfaceHostEditor\.openNew\}/);
|
||||
assert.match(source, /onEditHost=\{workSurfaceHostEditor\.openEdit\}/);
|
||||
assert.match(source, /terminal\.layer\.hostTree\.hostSavedNextConnection/);
|
||||
});
|
||||
207
application/app/AppHostEditorLayer.tsx
Normal file
207
application/app/AppHostEditorLayer.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import type { WorkSurfaceHostEditorTarget } from '../state/useWorkSurfaceHostEditor';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import HostDetailsPanel from '../../components/HostDetailsPanel';
|
||||
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
|
||||
import { PortalContainerProvider } from '../../components/ui/portal-container';
|
||||
import { resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from '@/infrastructure/config/storageKeys';
|
||||
import type {
|
||||
GroupConfig,
|
||||
Host,
|
||||
Identity,
|
||||
ManagedSource,
|
||||
ProxyProfile,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
TerminalSession,
|
||||
Workspace,
|
||||
} from '../../types';
|
||||
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
|
||||
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
|
||||
|
||||
export function resolveWorkSurfaceHostEditorKind(
|
||||
target: WorkSurfaceHostEditorTarget,
|
||||
): WorkSurfaceHostEditorKind {
|
||||
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
|
||||
? 'serial'
|
||||
: 'standard';
|
||||
}
|
||||
|
||||
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
|
||||
const segments = path?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= segments.length; index += 1) {
|
||||
groups.add(segments.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
export function collectWorkSurfaceHostGroups(
|
||||
hosts: Host[],
|
||||
customGroups: string[],
|
||||
groupConfigs: GroupConfig[],
|
||||
): string[] {
|
||||
const groups = new Set<string>();
|
||||
for (const path of customGroups) addGroupAndAncestors(groups, path);
|
||||
for (const config of groupConfigs) addGroupAndAncestors(groups, config.path);
|
||||
for (const host of hosts) addGroupAndAncestors(groups, host.group);
|
||||
return Array.from(groups).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function collectWorkSurfaceHostTags(hosts: Host[]): string[] {
|
||||
const tags = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
for (const tag of host.tags ?? []) tags.add(tag);
|
||||
}
|
||||
return Array.from(tags).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function getAppHostEditorLayerStyle(surfaceVisible: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: surfaceVisible ? undefined : 'none',
|
||||
pointerEvents: surfaceVisible ? undefined : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
interface AppHostEditorLayerProps {
|
||||
/** When omitted, surface visibility is derived from activeTabId in this leaf. */
|
||||
surfaceVisible?: boolean;
|
||||
target: WorkSurfaceHostEditorTarget | null;
|
||||
editorKey: string | null;
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
managedSources: ManagedSource[];
|
||||
snippets: Snippet[];
|
||||
terminalThemeId: string;
|
||||
terminalFontSize: number;
|
||||
/** Required when surfaceVisible is not passed (leaf active-tab subscription). */
|
||||
sessions?: TerminalSession[];
|
||||
workspaces?: Workspace[];
|
||||
logViews?: readonly LogView[];
|
||||
orderedTabs?: readonly string[];
|
||||
editorTabs?: readonly EditorTabChrome[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
onCreateGroup: (groupPath: string) => void;
|
||||
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
|
||||
onUpdateSnippets: (snippets: Snippet[]) => void;
|
||||
onUpdateHosts?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
|
||||
}
|
||||
|
||||
export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
surfaceVisible: surfaceVisibleProp,
|
||||
target,
|
||||
editorKey,
|
||||
hosts,
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
managedSources,
|
||||
snippets,
|
||||
terminalThemeId,
|
||||
terminalFontSize,
|
||||
sessions = [],
|
||||
workspaces = [],
|
||||
logViews = [],
|
||||
orderedTabs = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
onImportOrReuseKey,
|
||||
onUpdateSnippets,
|
||||
onUpdateHosts,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const derivedSurfaceVisible = useWorkSurfaceVisible({
|
||||
enabled: true,
|
||||
sessions,
|
||||
workspaces,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
});
|
||||
// Prefer explicit prop only when provided, so existing tests keep control.
|
||||
const surfaceVisible = surfaceVisibleProp ?? derivedSurfaceVisible;
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);
|
||||
const groups = useMemo(
|
||||
() => collectWorkSurfaceHostGroups(hosts, customGroups, groupConfigs),
|
||||
[customGroups, groupConfigs, hosts],
|
||||
);
|
||||
const allTags = useMemo(() => collectWorkSurfaceHostTags(hosts), [hosts]);
|
||||
const groupPath = target?.mode === 'edit'
|
||||
? target.openedHost.group
|
||||
: target?.defaultGroup;
|
||||
const groupDefaults = useMemo(
|
||||
() => (groupPath ? resolveGroupDefaults(groupPath, groupConfigs) : undefined),
|
||||
[groupConfigs, groupPath],
|
||||
);
|
||||
// Share width persistence with Vault host details so both entry points feel consistent.
|
||||
const hostPanelResizeProps = {
|
||||
resizable: true as const,
|
||||
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
|
||||
resizeAriaLabel: t('vault.panel.resizeWidth'),
|
||||
};
|
||||
|
||||
if (!target || !editorKey) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setPortalContainer}
|
||||
className="pointer-events-none absolute inset-0 z-40 [&>*]:pointer-events-auto"
|
||||
data-section="app-host-editor-layer"
|
||||
style={getAppHostEditorLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<PortalContainerProvider container={portalContainer}>
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
<SerialHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
allTags={allTags}
|
||||
groups={groups}
|
||||
groupDefaults={groupDefaults}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
layout="overlay"
|
||||
className="pointer-events-auto"
|
||||
{...hostPanelResizeProps}
|
||||
/>
|
||||
) : (
|
||||
<HostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.mode === 'edit' ? target.openedHost : null}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groups={groups}
|
||||
managedSources={managedSources}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
defaultGroup={target.mode === 'new' ? target.defaultGroup : undefined}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
groupDefaults={groupDefaults}
|
||||
groupConfigs={groupConfigs}
|
||||
snippets={snippets}
|
||||
onSnippetsChange={onUpdateSnippets}
|
||||
onHostsChange={onUpdateHosts}
|
||||
onImportKey={onImportOrReuseKey}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
onCreateGroup={onCreateGroup}
|
||||
layout="overlay"
|
||||
className="pointer-events-auto"
|
||||
{...hostPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
</PortalContainerProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
application/app/AppHostEditorSurface.tsx
Normal file
51
application/app/AppHostEditorSurface.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import { isHostTreeWorkTabSurface } from './workTabSurface';
|
||||
|
||||
/**
|
||||
* Subscribes to activeTabId and exposes work-surface visibility without
|
||||
* forcing the AppView shell to re-render on every top-tab switch.
|
||||
*/
|
||||
export function useWorkSurfaceVisible({
|
||||
enabled,
|
||||
sessions,
|
||||
workspaces,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
}): boolean {
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionIds = useMemo(
|
||||
() => new Set(sessions.map((session) => session.id)),
|
||||
[sessions],
|
||||
);
|
||||
const workspaceIds = useMemo(
|
||||
() => new Set(workspaces.map((workspace) => workspace.id)),
|
||||
[workspaces],
|
||||
);
|
||||
const logViewIds = useMemo(
|
||||
() => new Set(logViews.map((logView) => logView.id)),
|
||||
[logViews],
|
||||
);
|
||||
|
||||
return useMemo(() => isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}), [activeTabId, enabled, logViewIds, orderedTabs, sessionIds, workspaceIds]);
|
||||
}
|
||||
|
||||
/** Tiny marker export so tests can pin the isolation helper module. */
|
||||
export type WorkSurfaceEditorTabChrome = EditorTabChrome;
|
||||
60
application/app/AppHostTreeLayer.test.ts
Normal file
60
application/app/AppHostTreeLayer.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
getAppHostTreeLayerStyle,
|
||||
} = await import('./AppHostTreeLayer');
|
||||
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('shared host tree layer is visible above work tabs', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(true), {
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree layer is hidden behind root pages', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(false), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree does not force open when entering a work tab surface', () => {
|
||||
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
|
||||
});
|
||||
|
||||
test('host tree layer hides immediately when leaving work tab surfaces', () => {
|
||||
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
|
||||
});
|
||||
|
||||
test('shared host tree theme follows active chrome resolution and manual chrome injection', () => {
|
||||
assert.match(hostTreeLayerSource, /resolveActiveChromeTheme/);
|
||||
assert.match(hostTreeLayerSource, /useManualTerminalChromeSurfaceInjection/);
|
||||
assert.match(hostTreeLayerSource, /resolveSessionAppearance/);
|
||||
});
|
||||
|
||||
test('shared host tree forwards work-surface host management callbacks', () => {
|
||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
||||
});
|
||||
|
||||
test('shared host tree layer is memoized with a custom areEqual', () => {
|
||||
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
|
||||
});
|
||||
|
||||
183
application/app/AppHostTreeLayer.tsx
Normal file
183
application/app/AppHostTreeLayer.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
|
||||
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
|
||||
import type {
|
||||
ResolvedAppearance,
|
||||
TerminalAppearanceHostScope,
|
||||
} from '../../domain/terminalAppearanceRuntime';
|
||||
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
enabled: boolean;
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
onConnect: (host: Host) => void;
|
||||
onNewHost?: (defaultGroup?: string) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
|
||||
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
|
||||
return {
|
||||
visibility: surfaceVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: surfaceVisible ? 'auto' : 'none',
|
||||
zIndex: surfaceVisible ? 30 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function appHostTreeLayerAreEqual(
|
||||
prev: AppHostTreeLayerProps,
|
||||
next: AppHostTreeLayerProps,
|
||||
): boolean {
|
||||
return prev.enabled === next.enabled
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.customGroups === next.customGroups
|
||||
&& prev.groupConfigs === next.groupConfigs
|
||||
&& prev.sessions === next.sessions
|
||||
&& prev.workspaces === next.workspaces
|
||||
&& prev.editorTabs === next.editorTabs
|
||||
&& prev.logViews === next.logViews
|
||||
&& prev.orderedTabs === next.orderedTabs
|
||||
// accentMode / customAccent intentionally omitted — read from
|
||||
// appearanceChromeStore so accent drag does not rebuild the App shell.
|
||||
&& prev.currentTerminalTheme === next.currentTerminalTheme
|
||||
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
|
||||
&& prev.hostById === next.hostById
|
||||
&& prev.themeById === next.themeById
|
||||
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
|
||||
&& prev.onConnect === next.onConnect
|
||||
&& prev.onNewHost === next.onNewHost
|
||||
&& prev.onEditHost === next.onEditHost
|
||||
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
|
||||
}
|
||||
|
||||
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
currentTerminalTheme,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
resolveSessionAppearance,
|
||||
onConnect,
|
||||
onNewHost,
|
||||
onEditHost,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
|
||||
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
|
||||
const sessionById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.id, session])),
|
||||
[sessions],
|
||||
);
|
||||
const workspaceById = useMemo(
|
||||
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
[workspaces],
|
||||
);
|
||||
const surfaceVisible = isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
});
|
||||
|
||||
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
const hostTreeTheme = useMemo(() => (
|
||||
resolveActiveChromeTheme({
|
||||
accentMode,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}) ?? currentTerminalTheme
|
||||
), [
|
||||
accentMode,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
useManualTerminalChromeSurfaceInjection(
|
||||
hostTreeTheme,
|
||||
!followAppTerminalTheme && surfaceVisible,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
data-section="app-host-tree-layer"
|
||||
style={getAppHostTreeLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<TerminalHostTreeSidebar
|
||||
enabled={enabled}
|
||||
surfaceVisible={surfaceVisible}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
resolvedPreviewTheme={hostTreeTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
onNewHost={onNewHost}
|
||||
onEditHost={onEditHost}
|
||||
onCreateLocalTerminal={onCreateLocalTerminal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||
13
application/app/AppLocalState.tsx
Normal file
13
application/app/AppLocalState.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Owns dialog / queue / ephemeral React state for the main window.
|
||||
*
|
||||
* The concrete state currently lives in `AppSideEffects` (same React tree)
|
||||
* and is published into `appLocalUiStore` for Host islands. This provider is
|
||||
* the composition slot the architecture requires so App itself never owns
|
||||
* domain bags or mega-hook subscriptions.
|
||||
*/
|
||||
export function AppLocalStateProvider({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
78
application/app/AppMounts.test.ts
Normal file
78
application/app/AppMounts.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
|
||||
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
|
||||
const appViewSource = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
const appMountsSource = readFileSync(new URL('./AppMounts.tsx', import.meta.url), 'utf8');
|
||||
const globalCssSource = readFileSync(new URL('../../index.css', import.meta.url), 'utf8');
|
||||
|
||||
test('visible log view leaves room for the terminal host sidebar', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden log view remains hidden while preserving host sidebar offset', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: -1,
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('terminal layer renders only after terminal content is visible or mounted', () => {
|
||||
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
|
||||
});
|
||||
|
||||
test('inactive app surfaces suppress background color transitions', () => {
|
||||
assert.match(appMountsSource, /data-inactive-app-surface=\{isActive \? undefined : "true"\}/);
|
||||
assert.match(appMountsSource, /data-inactive-app-surface=\{isVisible \? undefined : "true"\}/);
|
||||
assert.match(globalCssSource, /\[data-inactive-app-surface\][\s\S]*transition: none !important;/);
|
||||
});
|
||||
|
||||
test('vault activation suppresses inherited text color transitions', () => {
|
||||
assert.match(appMountsSource, /data-app-surface-transition-suppressed/);
|
||||
assert.match(appMountsSource, /setSuppressActiveTransition\(false\)/);
|
||||
assert.match(globalCssSource, /\[data-app-surface-transition-suppressed\][\s\S]*transition: none !important;/);
|
||||
});
|
||||
|
||||
test('vault surface carries app theme vars while terminal chrome is active', () => {
|
||||
const appThemeStyleSource = readFileSync(new URL('./useAppThemeStyle.ts', import.meta.url), 'utf8');
|
||||
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
|
||||
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
|
||||
assert.match(appThemeStyleSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
|
||||
assert.match(appThemeStyleSource, /useAppearanceChromeStore/);
|
||||
assert.match(appViewSource, /VaultThemedSurface|useAppThemeStyle|appThemeStyle/);
|
||||
});
|
||||
|
||||
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
||||
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
|
||||
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
|
||||
});
|
||||
|
||||
test('terminal layer force-mounts immediately when a hidden MCP session exists', () => {
|
||||
// A silent session never becomes activeTabId, so without this it would wait
|
||||
// for the up-to-5s idle-callback fallback before TerminalPanesHost renders
|
||||
// TerminalPane and starts the PTY — racing an immediate terminal_execute.
|
||||
assert.match(appMountsSource, /hasHiddenSession = props\.sessions\.some\(\(session\) => session\.hiddenFromTabs\)/);
|
||||
assert.match(appMountsSource, /useState\(isVisible \|\| hasHiddenSession\)/);
|
||||
assert.match(appMountsSource, /if \(isVisible \|\| hasHiddenSession\) setShouldMount\(true\)/);
|
||||
});
|
||||
212
application/app/AppMounts.tsx
Normal file
212
application/app/AppMounts.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { isTerminalContentTabSurface } from './workTabSurface';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ConnectionLog, TerminalTheme } from '../../types';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import type { LogView as LogViewType } from '../state/logViewState';
|
||||
import type { SftpView as SftpViewComponent } from '../../components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from '../../components/TerminalLayer';
|
||||
|
||||
// Visibility container for VaultView - isolates isActive subscription
|
||||
export const VaultViewContainer: React.FC<{
|
||||
children: React.ReactNode;
|
||||
appThemeStyle?: React.CSSProperties;
|
||||
}> = ({ children, appThemeStyle }) => {
|
||||
const isActive = useIsVaultActive();
|
||||
const wasActiveRef = useRef(isActive);
|
||||
const [suppressActiveTransition, setSuppressActiveTransition] = useState(false);
|
||||
const isActivating = isActive && !wasActiveRef.current;
|
||||
const shouldSuppressTransition = isActivating || suppressActiveTransition;
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wasActive = wasActiveRef.current;
|
||||
wasActiveRef.current = isActive;
|
||||
if (!isActive || wasActive) return;
|
||||
|
||||
setSuppressActiveTransition(true);
|
||||
const view = window;
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
firstFrame = view.requestAnimationFrame(() => {
|
||||
secondFrame = view.requestAnimationFrame(() => {
|
||||
setSuppressActiveTransition(false);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
view.cancelAnimationFrame(firstFrame);
|
||||
view.cancelAnimationFrame(secondFrame);
|
||||
};
|
||||
}, [isActive]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute inset-0", isActive ? "z-20" : "")}
|
||||
data-inactive-app-surface={isActive ? undefined : "true"}
|
||||
data-app-surface-transition-suppressed={shouldSuppressTransition ? "true" : undefined}
|
||||
style={{ ...appThemeStyle, ...containerStyle }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// LogView wrapper - manages visibility based on active tab
|
||||
interface LogViewWrapperProps {
|
||||
logView: LogViewType;
|
||||
defaultTerminalTheme: TerminalTheme;
|
||||
defaultFontSize: number;
|
||||
onClose: () => void;
|
||||
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
|
||||
}
|
||||
|
||||
export function getLogViewWrapperStyle(
|
||||
isVisible: boolean,
|
||||
hostTreeLayoutWidth: number,
|
||||
): React.CSSProperties {
|
||||
const baseStyle = {
|
||||
left: hostTreeLayoutWidth,
|
||||
};
|
||||
return isVisible
|
||||
? baseStyle
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
|
||||
}
|
||||
|
||||
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isVisible = activeTabId === logView.id;
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute inset-0", isVisible ? "z-20" : "")}
|
||||
data-inactive-app-surface={isVisible ? undefined : "true"}
|
||||
style={containerStyle}
|
||||
>
|
||||
<LazyLoadBoundary name="Log view" resetKey={logView.id}>
|
||||
<Suspense fallback={<LogViewFallback />}>
|
||||
<LazyLogView
|
||||
log={logView.log}
|
||||
defaultTerminalTheme={defaultTerminalTheme}
|
||||
defaultFontSize={defaultFontSize}
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
onUpdateLog={onUpdateLog}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LazyLogView = lazy(() => import('../../components/LogView'));
|
||||
|
||||
const LazySftpView = lazy(() =>
|
||||
import('../../components/SftpView').then((m) => ({ default: m.SftpView })),
|
||||
);
|
||||
|
||||
const LazyTerminalLayer = lazy(() =>
|
||||
import('../../components/TerminalLayer').then((m) => ({ default: m.TerminalLayer })),
|
||||
);
|
||||
|
||||
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
|
||||
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
|
||||
|
||||
const LogViewFallback = () => (
|
||||
<div className="netcatty-lazy-fade-in h-full min-h-0 bg-background" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const SftpViewFallback = ({ visible }: { visible: boolean }) => {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
|
||||
);
|
||||
};
|
||||
|
||||
const TerminalLayerFallback = ({ visible }: { visible: boolean }) => {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
|
||||
);
|
||||
};
|
||||
|
||||
export function shouldRenderTerminalLayerMount(
|
||||
isVisible: boolean,
|
||||
shouldMount: boolean,
|
||||
): boolean {
|
||||
return isVisible || shouldMount;
|
||||
}
|
||||
|
||||
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
const isActive = useIsSftpActive();
|
||||
const [shouldMount, setShouldMount] = useState(isActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) setShouldMount(true);
|
||||
}, [isActive]);
|
||||
|
||||
if (!shouldMount) return null;
|
||||
|
||||
return (
|
||||
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
|
||||
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
|
||||
const isVisible = isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}) || !!props.draggingSessionId;
|
||||
// Silent MCP sessions never become the activeTabId, so `isVisible` alone
|
||||
// would leave this whole layer (and its PTY-starting TerminalPane) unmounted
|
||||
// for up to 5s (the idle-callback fallback below) after host_open returns —
|
||||
// long enough for an immediate terminal_execute to race an unstarted session.
|
||||
const hasHiddenSession = props.sessions.some((session) => session.hiddenFromTabs);
|
||||
const [shouldMount, setShouldMount] = useState(isVisible || hasHiddenSession);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible || hasHiddenSession) setShouldMount(true);
|
||||
}, [isVisible, hasHiddenSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldMount) return;
|
||||
type IdleWindow = Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (id: number) => void;
|
||||
};
|
||||
const idleWindow = window as IdleWindow;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
|
||||
return () => idleWindow.cancelIdleCallback?.(id);
|
||||
}
|
||||
const id = window.setTimeout(() => setShouldMount(true), 5000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [shouldMount]);
|
||||
|
||||
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<LazyLoadBoundary name="Terminal" resetKey={activeTabId}>
|
||||
<Suspense fallback={<TerminalLayerFallback visible={isVisible} />}>
|
||||
<LazyTerminalLayer {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
};
|
||||
40
application/app/AppPluginKeybindingHost.tsx
Normal file
40
application/app/AppPluginKeybindingHost.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { PluginContributionHost } from '../../components/plugins/PluginContributionHost';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import { resolveActivePluginKeybindingContext } from '../state/pluginContributionContexts';
|
||||
|
||||
/**
|
||||
* Leaf host for plugin keybindings so AppView does not subscribe to activeTabId.
|
||||
* Tab switches only re-render this small surface (and plugin lifecycle), not the shell.
|
||||
*/
|
||||
export function AppPluginKeybindingHost({
|
||||
locale,
|
||||
theme,
|
||||
themeTokens,
|
||||
sessions,
|
||||
workspaces,
|
||||
}: {
|
||||
locale: string;
|
||||
theme: string;
|
||||
themeTokens?: Record<string, string>;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
}) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const keybindingContext = useMemo(() => resolveActivePluginKeybindingContext({
|
||||
activeTabId,
|
||||
sessions,
|
||||
workspaces,
|
||||
}), [activeTabId, sessions, workspaces]);
|
||||
|
||||
return (
|
||||
<PluginContributionHost
|
||||
locale={locale}
|
||||
theme={theme}
|
||||
themeTokens={themeTokens}
|
||||
keybindingContext={keybindingContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
233
application/app/AppShell.architecture.test.ts
Normal file
233
application/app/AppShell.architecture.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appShellSource = readFileSync(join(here, 'AppShell.tsx'), 'utf8');
|
||||
const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
|
||||
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
|
||||
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
|
||||
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
|
||||
const appLockGateSource = readFileSync(join(here, '../../components/AppLockGate.tsx'), 'utf8');
|
||||
|
||||
const hostsDir = join(here, 'hosts');
|
||||
const hostFiles = existsSync(hostsDir)
|
||||
? readdirSync(hostsDir).filter((name) => name.endsWith('.tsx') || name.endsWith('.ts'))
|
||||
: [];
|
||||
const hostSources = Object.fromEntries(
|
||||
hostFiles.map((name) => [name, readFileSync(join(hostsDir, name), 'utf8')]),
|
||||
);
|
||||
|
||||
const MEGA_HOOKS = ['useVaultState', 'useSessionState', 'useSettingsState'] as const;
|
||||
const APP_RUNTIME_HOOKS = [
|
||||
'useAppVaultRuntime',
|
||||
'useAppSessionRuntime',
|
||||
'useAppSettingsRuntime',
|
||||
] as const;
|
||||
|
||||
test('AppShell does not co-host the vault/session/settings mega hooks', () => {
|
||||
for (const hook of MEGA_HOOKS) {
|
||||
assert.doesNotMatch(
|
||||
appShellSource,
|
||||
new RegExp(`${hook}\\s*\\(`),
|
||||
`AppShell must not call ${hook}(); Hosts subscribe to stores instead`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('AppShell composes the four Host islands', () => {
|
||||
assert.match(appShellSource, /<VaultHost\b/);
|
||||
assert.match(appShellSource, /<TerminalHost\b/);
|
||||
assert.match(appShellSource, /<ChromeHost\b/);
|
||||
assert.match(appShellSource, /<DialogsHost\b/);
|
||||
});
|
||||
|
||||
test('AppShell renders the shell from store-backed bags only', () => {
|
||||
assert.match(appShellSource, /useSyncExternalStore|useAppShellProps/);
|
||||
assert.match(appShellSource, /appViewDomainsEqual/);
|
||||
assert.match(appShellSource, /<AppView domains=\{domains\} \/>/);
|
||||
assert.match(appShellSource, /<AppActiveTabChrome \{\.\.\.chrome\} \/>/);
|
||||
});
|
||||
|
||||
test('App renders through AppShell and owns no domain bags', () => {
|
||||
assert.match(appSource, /<AppShell\b/);
|
||||
assert.doesNotMatch(appSource, /<AppView\b/);
|
||||
assert.doesNotMatch(appSource, /<AppActiveTabChrome\b/);
|
||||
assert.doesNotMatch(appSource, /<ConfirmDialog\b/);
|
||||
assert.doesNotMatch(appSource, /<PortForwardHostKeyDialog\b/);
|
||||
assert.doesNotMatch(appSource, /appVaultDomain\s*=/);
|
||||
assert.doesNotMatch(appSource, /appTerminalDomain\s*=/);
|
||||
assert.doesNotMatch(appSource, /appChromeDomain\s*=/);
|
||||
assert.doesNotMatch(appSource, /appDialogsDomain\s*=/);
|
||||
});
|
||||
|
||||
test('App.tsx does not subscribe to vault/session/settings runtimes or mega hooks', () => {
|
||||
for (const hook of MEGA_HOOKS) {
|
||||
assert.doesNotMatch(appSource, new RegExp(`\\b${hook}\\s*\\(`));
|
||||
}
|
||||
for (const hook of APP_RUNTIME_HOOKS) {
|
||||
assert.doesNotMatch(
|
||||
appSource,
|
||||
new RegExp(`\\b${hook}\\s*\\(`),
|
||||
`App.tsx must not call ${hook}(); move subscriptions into Hosts / AppSideEffects`,
|
||||
);
|
||||
}
|
||||
assert.match(appSource, /<VaultPublisher>/);
|
||||
assert.match(appSource, /<SessionPublisher\b/);
|
||||
assert.match(appSource, /<SettingsPublisher\b/);
|
||||
assert.match(appSource, /<AppSideEffects\b/);
|
||||
});
|
||||
|
||||
test('publishers and the app-lock gate own the mega hooks and store fan-out', () => {
|
||||
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
|
||||
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
|
||||
assert.match(appLockGateSource, /\buseSettingsState\s*\(/);
|
||||
assert.doesNotMatch(settingsPublisherSource, /\buseSettingsState\s*\(/);
|
||||
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
|
||||
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
|
||||
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
|
||||
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
|
||||
assert.match(sessionPublisherSource, /publishSessionSnapshot\(/);
|
||||
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(/);
|
||||
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(null\)/);
|
||||
});
|
||||
|
||||
test('publishers hand their runtime to App through the app runtime bridge', () => {
|
||||
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(vault\)/);
|
||||
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(null\)/);
|
||||
assert.match(vaultPublisherSource, /<AppVaultRuntimeContext\.Provider value=\{vaultForApp\}>/);
|
||||
assert.match(vaultPublisherSource, /notes: _notes/);
|
||||
assert.match(vaultPublisherSource, /connectionLogs: _connectionLogs/);
|
||||
|
||||
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(session\)/);
|
||||
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(null\)/);
|
||||
assert.match(sessionPublisherSource, /<AppSessionRuntimeContext\.Provider value=\{session\}>/);
|
||||
|
||||
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
|
||||
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(null\)/);
|
||||
assert.match(settingsPublisherSource, /<AppSettingsRuntimeContext\.Provider value=\{settings\}>/);
|
||||
});
|
||||
|
||||
test('VaultHost builds from vault snapshot stores', () => {
|
||||
const source = hostSources['VaultHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/VaultHost.tsx must exist');
|
||||
assert.match(source, /useVaultSnapshot/);
|
||||
assert.match(source, /useVaultSnapshotActions|getVaultSnapshotActions/);
|
||||
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('TerminalHost builds from session snapshot + terminal settings store', () => {
|
||||
const source = hostSources['TerminalHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/TerminalHost.tsx must exist');
|
||||
assert.match(source, /useSessionSnapshot/);
|
||||
assert.match(source, /useSessionSnapshotActions|getSessionSnapshotActions/);
|
||||
assert.match(source, /useTerminalSettingsStore|getTerminalSettingsSnapshot/);
|
||||
assert.doesNotMatch(source, /\buseSessionState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppSessionRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
|
||||
const source = hostSources['ChromeHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/ChromeHost.tsx must exist');
|
||||
assert.match(source, /useSettingsChromeStore|getSettingsChromeSnapshot/);
|
||||
assert.match(source, /useSessionSnapshot|useSessionSnapshotField|getSessionSnapshot/);
|
||||
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
|
||||
assert.doesNotMatch(source, /\buseSettingsState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
|
||||
const source = hostSources['DialogsHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
|
||||
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
|
||||
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('AppSideEffects may use runtime hooks; App must not', () => {
|
||||
const sideEffectsPath = join(here, 'AppSideEffects.tsx');
|
||||
assert.ok(existsSync(sideEffectsPath), 'application/app/AppSideEffects.tsx must exist');
|
||||
const sideEffectsSource = readFileSync(sideEffectsPath, 'utf8');
|
||||
assert.doesNotMatch(sideEffectsSource, /\bfunction App\b|\bconst App\b/);
|
||||
assert.match(
|
||||
sideEffectsSource,
|
||||
/useAppVaultRuntime|useAppSessionRuntime|useAppSettingsRuntime|getAppVaultRuntime|getAppSessionRuntime|getAppSettingsRuntime/,
|
||||
);
|
||||
});
|
||||
|
||||
test('AppSideEffects does not build domain bags for Hosts', () => {
|
||||
const sideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
|
||||
assert.doesNotMatch(sideEffectsSource, /appVaultDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appTerminalDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appChromeDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appDialogsDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appMountsDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appViewDomains\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appShellChrome\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appShellOverlays\s*=/);
|
||||
// Flat glue only — no prepared domain bags on the handlers bridge.
|
||||
assert.doesNotMatch(sideEffectsSource, /vaultDomain\s*:/);
|
||||
assert.doesNotMatch(sideEffectsSource, /terminalDomain\s*:/);
|
||||
assert.doesNotMatch(sideEffectsSource, /chromeDomain\s*:/);
|
||||
assert.doesNotMatch(sideEffectsSource, /dialogsDomain\s*:/);
|
||||
assert.doesNotMatch(sideEffectsSource, /mountsDomain\s*:/);
|
||||
assert.match(sideEffectsSource, /registerAppHandlers\s*\(/);
|
||||
assert.match(sideEffectsSource, /publishAppLocalUi\s*\(/);
|
||||
});
|
||||
|
||||
test('Hosts assemble bags field-by-field without spreading prepared domains', () => {
|
||||
for (const name of ['VaultHost.tsx', 'TerminalHost.tsx', 'ChromeHost.tsx', 'DialogsHost.tsx']) {
|
||||
const source = hostSources[name];
|
||||
assert.ok(source, `application/app/hosts/${name} must exist`);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/handlers\?\.(vault|terminal|chrome|dialogs)Domain|handlers\?\.appShellChrome|handlers\?\.appShellOverlays/,
|
||||
`${name} must not read prepared *Domain / appShell* bags from handlers`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\.\.\.\s*prepared/,
|
||||
`${name} must not spread a prepared domain bag`,
|
||||
);
|
||||
assert.match(source, /getAppHandlers|subscribeAppHandlers/);
|
||||
}
|
||||
});
|
||||
|
||||
test('published Host bags omit notes, accent, and connectionLogs churn fields', () => {
|
||||
const vaultHost = hostSources['VaultHost.tsx'];
|
||||
const terminalHost = hostSources['TerminalHost.tsx'];
|
||||
assert.ok(vaultHost && terminalHost);
|
||||
|
||||
// Notes / connection logs live in dedicated stores — VaultHost must not
|
||||
// publish them into the vault domain bag that AppView consumes.
|
||||
assert.doesNotMatch(vaultHost, /\bnotes\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /\bconnectionLogs\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /notesStore|connectionLogsStore|useNotesStore|useConnectionLogs/);
|
||||
|
||||
// Accent feeds useThemeRuntime for local injection only; the published
|
||||
// terminal domain bag must not list accentMode/customAccent fields.
|
||||
const domainStart = terminalHost.indexOf('const terminalDomain = useMemo');
|
||||
assert.notEqual(domainStart, -1);
|
||||
const domain = terminalHost.slice(domainStart, terminalHost.indexOf('useLayoutEffect(() => {\n if (terminalDomain)', domainStart));
|
||||
assert.doesNotMatch(domain, /accentMode/);
|
||||
assert.doesNotMatch(domain, /customAccent/);
|
||||
assert.match(domain, /currentTerminalTheme,/);
|
||||
assert.match(
|
||||
terminalHost,
|
||||
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||
);
|
||||
});
|
||||
|
||||
test('AppShell uses default memo (store-driven), not always-rerender comparator', () => {
|
||||
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
|
||||
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
|
||||
});
|
||||
|
||||
test('terminal system detection routes through the owner of temporary hosts', () => {
|
||||
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
|
||||
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
|
||||
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
|
||||
});
|
||||
68
application/app/AppShell.tsx
Normal file
68
application/app/AppShell.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { ConfirmDialog } from '../../components/ui/confirm-dialog';
|
||||
import { PortForwardHostKeyDialog } from '../../components/port-forwarding';
|
||||
import { AppActiveTabChrome } from './AppActiveTabChrome';
|
||||
import { AppView } from './AppView';
|
||||
import {
|
||||
useAppShellProps,
|
||||
type AppShellOverlays,
|
||||
} from './appShellPropsStore';
|
||||
import { ChromeHost } from './hosts/ChromeHost';
|
||||
import { DialogsHost } from './hosts/DialogsHost';
|
||||
import { TerminalHost } from './hosts/TerminalHost';
|
||||
import { VaultHost } from './hosts/VaultHost';
|
||||
|
||||
export type { AppShellOverlays };
|
||||
|
||||
/**
|
||||
* The rendered main window. Host islands subscribe to stores and publish
|
||||
* domain / chrome / overlay bags into `appShellPropsStore`; this shell only
|
||||
* re-renders when those bag identities change (see `appViewDomainsEqual` /
|
||||
* chrome / overlays identity checks in the store).
|
||||
*
|
||||
* Default `memo` is enough: AppShell takes no props, so parent App updates
|
||||
* do not force a re-render; store subscriptions via `useAppShellProps` still
|
||||
* drive updates when Hosts publish new bag identities.
|
||||
*
|
||||
* `AppShell.architecture.test.ts` enforces Host composition and that the
|
||||
* three mega hooks never reappear here.
|
||||
*/
|
||||
function AppShellView() {
|
||||
const { t } = useI18n();
|
||||
const { domains, chrome, overlays } = useAppShellProps();
|
||||
|
||||
return (
|
||||
<>
|
||||
<VaultHost />
|
||||
<TerminalHost />
|
||||
<ChromeHost />
|
||||
<DialogsHost />
|
||||
{domains && chrome && overlays ? (
|
||||
<>
|
||||
<PortForwardHostKeyDialog onAddKnownHost={overlays.onAddKnownHost} />
|
||||
<ConfirmDialog
|
||||
open={overlays.deleteHostConfirm !== null}
|
||||
title={
|
||||
overlays.deleteHostConfirm
|
||||
? t('confirm.deleteHost', { name: overlays.deleteHostConfirm.name })
|
||||
: ''
|
||||
}
|
||||
confirmLabel={t('action.delete')}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) overlays.onCancelDeleteHost();
|
||||
}}
|
||||
onConfirm={overlays.onConfirmDeleteHost}
|
||||
/>
|
||||
<AppActiveTabChrome {...chrome} />
|
||||
<AppView domains={domains} />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const AppShell = memo(AppShellView);
|
||||
AppShell.displayName = 'AppShell';
|
||||
18
application/app/AppSideEffects.appLock.test.ts
Normal file
18
application/app/AppSideEffects.appLock.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("open-terminal requests wait behind App Lock and resume after unlock", () => {
|
||||
const handlerStart = source.indexOf("const _handleOpenTerminalPath");
|
||||
const handlerEnd = source.indexOf("useEffect(() =>", handlerStart);
|
||||
const handlerSource = source.slice(handlerStart, handlerEnd);
|
||||
const drainStart = source.indexOf("const pending = pendingDeepLinksWhileLockedRef.current.splice(0)");
|
||||
const drainEnd = source.indexOf("}, [appLockLocked]);", drainStart);
|
||||
const drainSource = source.slice(drainStart, drainEnd);
|
||||
|
||||
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
|
||||
assert.match(handlerSource, /kind: 'open-terminal-path'/);
|
||||
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
|
||||
});
|
||||
15
application/app/AppSideEffects.paneMagnification.test.ts
Normal file
15
application/app/AppSideEffects.paneMagnification.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('terminal Escape restoration runs before xterm can stop propagation', () => {
|
||||
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
|
||||
const effectSource = source.slice(effectStart, effectStart + 900);
|
||||
|
||||
assert.notEqual(effectStart, -1);
|
||||
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
|
||||
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||
});
|
||||
30
application/app/AppSideEffects.snippetsDelete.test.ts
Normal file
30
application/app/AppSideEffects.snippetsDelete.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("snippets delete handler cleans host bindings via deleteSelectedSnippets", () => {
|
||||
assert.match(source, /collectSnippetDeleteIds/);
|
||||
assert.match(source, /deleteSelectedSnippets/);
|
||||
assert.match(
|
||||
source,
|
||||
/netcatty:snippets:delete[\s\S]*void deleteSelectedSnippets\(ids\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/updateSnippets\(snippets\.filter\(\(s\) => !ids\.has\(s\.id\)\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippets delete handler uses vault live snapshot instead of component refs", () => {
|
||||
// Component-level snippetsRef/hostsRef lag concurrent vault mutations that
|
||||
// already advanced useVaultState refs before React re-renders AppSideEffects.
|
||||
// Deletion must go through the vault hook's atomic live-snapshot path.
|
||||
assert.match(source, /deleteSelectedSnippets,/);
|
||||
assert.doesNotMatch(source, /snippetsRef\.current\s*=\s*snippets/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current/,
|
||||
);
|
||||
});
|
||||
2017
application/app/AppSideEffects.tsx
Normal file
2017
application/app/AppSideEffects.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
application/app/AppView.activeTabIsolation.test.ts
Normal file
23
application/app/AppView.activeTabIsolation.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
|
||||
const pluginHostSource = readFileSync(new URL("./AppPluginKeybindingHost.tsx", import.meta.url), "utf8");
|
||||
const editorSurfaceSource = readFileSync(new URL("./AppHostEditorSurface.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("AppView shell does not subscribe to useActiveTabId", () => {
|
||||
// Top-tab switches must not rebuild the AppView shell. Leaves own the subscription.
|
||||
assert.doesNotMatch(appViewSource, /useActiveTabId\s*\(/);
|
||||
assert.doesNotMatch(appViewSource, /useActiveTabId/);
|
||||
// Still uses activeTabStore for imperative tab close / neighbor activation.
|
||||
assert.match(appViewSource, /activeTabStore/);
|
||||
});
|
||||
|
||||
test("plugin keybindings and host-editor surface subscribe as leaves", () => {
|
||||
assert.match(pluginHostSource, /useActiveTabId/);
|
||||
assert.match(pluginHostSource, /resolveActivePluginKeybindingContext/);
|
||||
assert.match(editorSurfaceSource, /useWorkSurfaceVisible/);
|
||||
assert.match(editorSurfaceSource, /useActiveTabId/);
|
||||
assert.match(appViewSource, /AppPluginKeybindingHost/);
|
||||
});
|
||||
1064
application/app/AppView.tsx
Normal file
1064
application/app/AppView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
19
application/app/AppView.workspaceHostAppend.test.ts
Normal file
19
application/app/AppView.workspaceHostAppend.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('workspace append resolves group defaults before creating host sessions', () => {
|
||||
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
|
||||
assert.match(source, /groupConfigs,/);
|
||||
assert.match(source, /proxyProfiles,/);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
|
||||
);
|
||||
});
|
||||
290
application/app/activeChromeTheme.test.ts
Normal file
290
application/app/activeChromeTheme.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { toEditorTabId } from "../state/activeTabStore.ts";
|
||||
import type { EditorTab } from "../state/editorTabStore.ts";
|
||||
import type { LogView } from "../state/logViewState.ts";
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background: type === "dark" ? "#111111" : "#eeeeee",
|
||||
foreground: type === "dark" ? "#eeeeee" : "#111111",
|
||||
cursor: "#22aaff",
|
||||
},
|
||||
});
|
||||
|
||||
const currentTheme = theme("current");
|
||||
const hostTheme = theme("host-theme");
|
||||
const logTheme = theme("log-theme", "light");
|
||||
|
||||
const baseInput = {
|
||||
accentMode: "theme" as const,
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: "221.2 83.2% 53.3%",
|
||||
editorTabs: [],
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map<string, Host>(),
|
||||
logViews: [],
|
||||
sessionById: new Map<string, TerminalSession>(),
|
||||
themeById: new Map([
|
||||
[currentTheme.id, currentTheme],
|
||||
[hostTheme.id, hostTheme],
|
||||
[logTheme.id, logTheme],
|
||||
]),
|
||||
workspaceById: new Map<string, Workspace>(),
|
||||
};
|
||||
|
||||
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("follow-app chrome applies custom accent onto the published base theme", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
accentMode: "custom",
|
||||
customAccent: "0 100% 50%",
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
assert.notEqual(resolved?.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "log-1",
|
||||
logViews: [{
|
||||
id: "log-1",
|
||||
connectionLogId: "1",
|
||||
log: { id: "1", themeId: logTheme.id },
|
||||
} as unknown as LogView],
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, logTheme.id);
|
||||
});
|
||||
|
||||
test("root pages use the normal application theme", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "vault",
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test("follow-app workspace split view always uses the global terminal theme", () => {
|
||||
const workspace: Workspace = {
|
||||
id: "ws-1",
|
||||
name: "Workspace",
|
||||
viewMode: "split",
|
||||
focusedSessionId: "session-1",
|
||||
root: {
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
sizes: [50, 50],
|
||||
children: [
|
||||
{ type: "session", sessionId: "session-1" },
|
||||
{ type: "session", sessionId: "session-2" },
|
||||
],
|
||||
},
|
||||
} as unknown as Workspace;
|
||||
|
||||
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
|
||||
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "ws-1",
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-a", hostA],
|
||||
["host-b", hostB],
|
||||
]),
|
||||
sessionById: new Map([
|
||||
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
|
||||
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
|
||||
]),
|
||||
workspaceById: new Map([["ws-1", workspace]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("manual workspace split view uses the focused session theme when panes differ", () => {
|
||||
const workspace: Workspace = {
|
||||
id: "ws-1",
|
||||
name: "Workspace",
|
||||
viewMode: "split",
|
||||
focusedSessionId: "session-2",
|
||||
root: {
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
sizes: [50, 50],
|
||||
children: [
|
||||
{ type: "pane", sessionId: "session-1" },
|
||||
{ type: "pane", sessionId: "session-2" },
|
||||
],
|
||||
},
|
||||
} as unknown as Workspace;
|
||||
|
||||
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
|
||||
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
|
||||
const focusedTheme = theme("focused-intent");
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "ws-1",
|
||||
hostById: new Map([
|
||||
["host-a", hostA],
|
||||
["host-b", hostB],
|
||||
]),
|
||||
sessionById: new Map([
|
||||
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
|
||||
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
|
||||
]),
|
||||
workspaceById: new Map([["ws-1", workspace]]),
|
||||
resolveSessionAppearance: ({ host }) => (
|
||||
host?.id === "host-b"
|
||||
? { themeId: focusedTheme.id, theme: focusedTheme, source: "intent", appThemeUpdate: null }
|
||||
: { themeId: hostTheme.id, theme: hostTheme, source: "host-override", appThemeUpdate: null }
|
||||
),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, focusedTheme.id);
|
||||
});
|
||||
|
||||
test("manual split workspace falls back to the first tree session theme when focus is stale", () => {
|
||||
const workspace: Workspace = {
|
||||
id: "ws-1",
|
||||
name: "Workspace",
|
||||
viewMode: "split",
|
||||
focusedSessionId: "missing-session",
|
||||
root: {
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
sizes: [50, 50],
|
||||
children: [
|
||||
{ type: "pane", sessionId: "session-1" },
|
||||
{ type: "pane", sessionId: "session-2" },
|
||||
],
|
||||
},
|
||||
} as unknown as Workspace;
|
||||
|
||||
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
|
||||
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "ws-1",
|
||||
hostById: new Map([
|
||||
["host-a", hostA],
|
||||
["host-b", hostB],
|
||||
]),
|
||||
sessionById: new Map([
|
||||
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
|
||||
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
|
||||
]),
|
||||
workspaceById: new Map([["ws-1", workspace]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("manual mode prefers runtime session appearance over stale host theme ids", () => {
|
||||
const intentTheme = theme("intent-theme");
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "session-1",
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id, themeOverride: true } as unknown as Host],
|
||||
]),
|
||||
sessionById: new Map([
|
||||
["session-1", { id: "session-1", hostId: "host-1" } as TerminalSession],
|
||||
]),
|
||||
resolveSessionAppearance: () => ({
|
||||
themeId: intentTheme.id,
|
||||
theme: intentTheme,
|
||||
source: "intent",
|
||||
appThemeUpdate: null,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, intentTheme.id);
|
||||
});
|
||||
|
||||
test("chrome theme sync waits until a newly opened session is present in deps", () => {
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map(),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
125
application/app/activeChromeTheme.ts
Normal file
125
application/app/activeChromeTheme.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
|
||||
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
|
||||
import type {
|
||||
ResolvedAppearance,
|
||||
TerminalAppearanceHostScope,
|
||||
} from "../../domain/terminalAppearanceRuntime";
|
||||
import { collectSessionIds } from "../../domain/workspace";
|
||||
import type { EditorTabChrome } from "../state/editorTabStore";
|
||||
import type { LogView } from "../state/logViewState";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
import { resolveWorkspaceTargetSessionFromMap } from "./workTabSurface";
|
||||
|
||||
export type ResolveActiveChromeThemeInput = {
|
||||
accentMode: "theme" | "custom";
|
||||
activeTabId: string;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
sessionById: ReadonlyMap<string, TerminalSession>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
workspaceById: ReadonlyMap<string, Workspace>;
|
||||
};
|
||||
|
||||
export function isActiveChromeThemeResolvable({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
logViews,
|
||||
sessionById,
|
||||
workspaceById,
|
||||
}: Pick<
|
||||
ResolveActiveChromeThemeInput,
|
||||
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
|
||||
>): boolean {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return true;
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
|
||||
}
|
||||
if (logViews.some((item) => item.id === activeTabId)) return true;
|
||||
if (workspaceById.has(activeTabId)) return true;
|
||||
if (sessionById.has(activeTabId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveActiveChromeTheme({
|
||||
accentMode,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveHostScope = (hostId: string): TerminalAppearanceHostScope => {
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
return { host, isEphemeral: !host || !hostById.has(host.id) };
|
||||
};
|
||||
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
if (resolveSessionAppearance) {
|
||||
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
|
||||
}
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
|
||||
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTabId = fromEditorTabId(activeTabId);
|
||||
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
|
||||
if (!editorTab) return null;
|
||||
return resolveHostTheme(editorTab.hostId);
|
||||
}
|
||||
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
const base = explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(base, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
if (workspace.viewMode === "focus") {
|
||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
return focusedSession ? resolveSessionTheme(focusedSession) : null;
|
||||
}
|
||||
|
||||
const workspaceSessions = collectSessionIds(workspace.root)
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (workspaceSessions.length === 0) return null;
|
||||
|
||||
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
|
||||
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
|
||||
if (allSame) return firstTheme;
|
||||
|
||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
return focusedSession ? resolveSessionTheme(focusedSession) : null;
|
||||
}
|
||||
|
||||
const session = sessionById.get(activeTabId);
|
||||
return session ? resolveSessionTheme(session) : null;
|
||||
}
|
||||
64
application/app/appHandlersBridge.ts
Normal file
64
application/app/appHandlersBridge.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Stable accessor bridge for App-local handlers that Host islands need when
|
||||
* assembling domain bags. `AppSideEffects` registers the live glue via
|
||||
* `useLayoutEffect`; Hosts call `getAppHandlers()` instead of receiving mega
|
||||
* props from App.
|
||||
*
|
||||
* Keep this intentionally loose (`Record<string, unknown>`): the handler set
|
||||
* tracks App glue, not a frozen public API.
|
||||
*/
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type AppHandlers = Record<string, unknown>;
|
||||
|
||||
function handlersShallowEqual(prev: AppHandlers, next: AppHandlers): boolean {
|
||||
const prevKeys = Object.keys(prev);
|
||||
const nextKeys = Object.keys(next);
|
||||
if (prevKeys.length !== nextKeys.length) return false;
|
||||
for (const key of nextKeys) {
|
||||
if (prev[key] !== next[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class AppHandlersBridge {
|
||||
private handlers: AppHandlers | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
get = (): AppHandlers | null => this.handlers;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
set(next: AppHandlers | null): void {
|
||||
if (this.handlers === next) return;
|
||||
if (
|
||||
this.handlers
|
||||
&& next
|
||||
&& handlersShallowEqual(this.handlers, next)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.handlers = next;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
const bridge = new AppHandlersBridge();
|
||||
|
||||
export function registerAppHandlers(handlers: AppHandlers | null): void {
|
||||
bridge.set(handlers);
|
||||
}
|
||||
|
||||
export function getAppHandlers(): AppHandlers | null {
|
||||
return bridge.get();
|
||||
}
|
||||
|
||||
export function subscribeAppHandlers(listener: Listener): () => void {
|
||||
return bridge.subscribe(listener);
|
||||
}
|
||||
116
application/app/appLocalUiStore.ts
Normal file
116
application/app/appLocalUiStore.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { Host, PortForwardingRule } from '../../domain/models';
|
||||
import type { VaultSection } from '../../components/VaultView';
|
||||
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* Dialog / queue / ephemeral UI owned outside the vault/session/settings
|
||||
* mega hooks. DialogsHost / VaultHost / TerminalHost subscribe here so
|
||||
* AppSideEffects never has to rebuild domain bags when a modal opens.
|
||||
*
|
||||
* `portForwardingRules` is published here as a thin derived slice: the PF
|
||||
* hook still lives in AppSideEffects (tray / sync / auto-start), but Hosts
|
||||
* must not receive a prepared terminal domain bag.
|
||||
*/
|
||||
export type AppLocalUiSnapshot = {
|
||||
isQuickSwitcherOpen: boolean;
|
||||
isCreateWorkspaceOpen: boolean;
|
||||
addToWorkspaceDialog:
|
||||
| { mode: 'append'; workspaceId: string }
|
||||
| { mode: 'create' }
|
||||
| null;
|
||||
quickSearch: string;
|
||||
protocolSelectHost: Host | null;
|
||||
navigateToSection: VaultSection | null;
|
||||
deepLinkHostDraft: Host | null;
|
||||
ephemeralHosts: readonly Host[];
|
||||
portForwardingRules: readonly PortForwardingRule[];
|
||||
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
|
||||
passphraseQueue: readonly PassphraseRequest[];
|
||||
deleteHostConfirm: { hostId: string; name: string } | null;
|
||||
vaultFocusRequest: unknown;
|
||||
openNoteRequest: unknown;
|
||||
emptyVaultConflict: unknown;
|
||||
};
|
||||
|
||||
export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
|
||||
isQuickSwitcherOpen: false,
|
||||
isCreateWorkspaceOpen: false,
|
||||
addToWorkspaceDialog: null,
|
||||
quickSearch: '',
|
||||
protocolSelectHost: null,
|
||||
navigateToSection: null,
|
||||
deepLinkHostDraft: null,
|
||||
ephemeralHosts: Object.freeze([]) as readonly Host[],
|
||||
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
|
||||
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
|
||||
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
|
||||
deleteHostConfirm: null,
|
||||
vaultFocusRequest: null,
|
||||
openNoteRequest: null,
|
||||
emptyVaultConflict: null,
|
||||
});
|
||||
class AppLocalUiStore {
|
||||
private snapshot: AppLocalUiSnapshot = EMPTY_APP_LOCAL_UI;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): AppLocalUiSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: AppLocalUiSnapshot): void {
|
||||
const prev = this.snapshot;
|
||||
if (
|
||||
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
|
||||
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
|
||||
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
|
||||
&& prev.quickSearch === next.quickSearch
|
||||
&& prev.protocolSelectHost === next.protocolSelectHost
|
||||
&& prev.navigateToSection === next.navigateToSection
|
||||
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
|
||||
&& prev.ephemeralHosts === next.ephemeralHosts
|
||||
&& prev.portForwardingRules === next.portForwardingRules
|
||||
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
|
||||
&& prev.passphraseQueue === next.passphraseQueue
|
||||
&& prev.deleteHostConfirm === next.deleteHostConfirm
|
||||
&& prev.vaultFocusRequest === next.vaultFocusRequest
|
||||
&& prev.openNoteRequest === next.openNoteRequest
|
||||
&& prev.emptyVaultConflict === next.emptyVaultConflict
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export const appLocalUiStore = new AppLocalUiStore();
|
||||
|
||||
export function publishAppLocalUi(snapshot: AppLocalUiSnapshot): void {
|
||||
appLocalUiStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getAppLocalUiSnapshot(): AppLocalUiSnapshot {
|
||||
return appLocalUiStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeAppLocalUi(listener: Listener): () => void {
|
||||
return appLocalUiStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useAppLocalUiStore(): AppLocalUiSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribeAppLocalUi,
|
||||
getAppLocalUiSnapshot,
|
||||
getAppLocalUiSnapshot,
|
||||
);
|
||||
}
|
||||
120
application/app/appShellPropsStore.ts
Normal file
120
application/app/appShellPropsStore.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { KnownHost } from '../../types';
|
||||
import type { AppActiveTabChromeProps } from './AppActiveTabChrome';
|
||||
import { appViewDomainsEqual, type AppViewDomains } from './appViewDomains';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type AppShellOverlays = {
|
||||
onAddKnownHost: (knownHost: KnownHost) => void;
|
||||
deleteHostConfirm: { hostId: string; name: string } | null;
|
||||
onCancelDeleteHost: () => void;
|
||||
onConfirmDeleteHost: () => void;
|
||||
};
|
||||
|
||||
export type AppShellPropsSnapshot = {
|
||||
domains: AppViewDomains | null;
|
||||
chrome: AppActiveTabChromeProps | null;
|
||||
overlays: AppShellOverlays | null;
|
||||
};
|
||||
|
||||
const EMPTY: AppShellPropsSnapshot = Object.freeze({
|
||||
domains: null,
|
||||
chrome: null,
|
||||
overlays: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Host islands publish domain / chrome / overlay bags here. `AppShell`
|
||||
* subscribes via `useSyncExternalStore` and only re-renders when bag
|
||||
* identities change (`appViewDomainsEqual` + chrome/overlays identity).
|
||||
*/
|
||||
class AppShellPropsStore {
|
||||
private snapshot: AppShellPropsSnapshot = EMPTY;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): AppShellPropsSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setDomains(domains: AppViewDomains): void {
|
||||
const prev = this.snapshot;
|
||||
if (prev.domains && appViewDomainsEqual(prev.domains, domains)) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = { ...prev, domains };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
setChrome(chrome: AppActiveTabChromeProps): void {
|
||||
if (this.snapshot.chrome === chrome) return;
|
||||
this.snapshot = { ...this.snapshot, chrome };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
setOverlays(overlays: AppShellOverlays): void {
|
||||
if (this.snapshot.overlays === overlays) return;
|
||||
this.snapshot = { ...this.snapshot, overlays };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
setDomainSlice<K extends keyof AppViewDomains>(
|
||||
key: K,
|
||||
slice: AppViewDomains[K],
|
||||
): void {
|
||||
const prev = this.snapshot.domains;
|
||||
if (prev && prev[key] === slice) return;
|
||||
const domains = {
|
||||
vault: prev?.vault ?? {},
|
||||
terminal: prev?.terminal ?? {},
|
||||
chrome: prev?.chrome ?? {},
|
||||
dialogs: prev?.dialogs ?? {},
|
||||
mounts: prev?.mounts ?? {},
|
||||
[key]: slice,
|
||||
} as AppViewDomains;
|
||||
this.setDomains(domains);
|
||||
}
|
||||
}
|
||||
|
||||
export const appShellPropsStore = new AppShellPropsStore();
|
||||
|
||||
export function publishAppShellDomains(domains: AppViewDomains): void {
|
||||
appShellPropsStore.setDomains(domains);
|
||||
}
|
||||
|
||||
export function publishAppShellDomainSlice<K extends keyof AppViewDomains>(
|
||||
key: K,
|
||||
slice: AppViewDomains[K],
|
||||
): void {
|
||||
appShellPropsStore.setDomainSlice(key, slice);
|
||||
}
|
||||
|
||||
export function publishAppShellChrome(chrome: AppActiveTabChromeProps): void {
|
||||
appShellPropsStore.setChrome(chrome);
|
||||
}
|
||||
|
||||
export function publishAppShellOverlays(overlays: AppShellOverlays): void {
|
||||
appShellPropsStore.setOverlays(overlays);
|
||||
}
|
||||
|
||||
export function getAppShellPropsSnapshot(): AppShellPropsSnapshot {
|
||||
return appShellPropsStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeAppShellProps(listener: Listener): () => void {
|
||||
return appShellPropsStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useAppShellProps(): AppShellPropsSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribeAppShellProps,
|
||||
getAppShellPropsSnapshot,
|
||||
getAppShellPropsSnapshot,
|
||||
);
|
||||
}
|
||||
57
application/app/appViewDomains.test.ts
Normal file
57
application/app/appViewDomains.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
appViewDomainsEqual,
|
||||
mergeAppViewDomains,
|
||||
type AppViewDomains,
|
||||
} from './appViewDomains.ts';
|
||||
|
||||
test('appViewDomainsEqual is true only when all domain slice refs match', () => {
|
||||
const vault = { hosts: [] };
|
||||
const terminal = { sessions: [] };
|
||||
const chrome = { theme: 'dark' };
|
||||
const dialogs = { open: false };
|
||||
const mounts = { TerminalLayerMount: null };
|
||||
const a: AppViewDomains = { vault, terminal, chrome, dialogs, mounts };
|
||||
const b: AppViewDomains = { vault, terminal, chrome, dialogs, mounts };
|
||||
assert.equal(appViewDomainsEqual(a, b), true);
|
||||
assert.equal(
|
||||
appViewDomainsEqual(a, { ...b, terminal: { sessions: [{ id: 'x' }] } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
appViewDomainsEqual(a, { ...b, vault: { hosts: [{ id: 'h' }] } }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('mergeAppViewDomains flattens domains without shellHistory requirement', () => {
|
||||
const merged = mergeAppViewDomains({
|
||||
vault: { hosts: [1], notes: [] },
|
||||
terminal: { sessions: [2] },
|
||||
chrome: { orderedTabs: [] },
|
||||
dialogs: { isQuickSwitcherOpen: false },
|
||||
mounts: { VaultViewContainer: 'V' },
|
||||
});
|
||||
assert.deepEqual(merged.hosts, [1]);
|
||||
assert.deepEqual(merged.sessions, [2]);
|
||||
assert.equal(merged.VaultViewContainer, 'V');
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(merged, 'shellHistory'), false);
|
||||
});
|
||||
|
||||
test('appViewDomainsEqual keeps AppView stable when only unrelated domain ref is same', () => {
|
||||
const vault = { hosts: [] };
|
||||
const terminal = { sessions: [] };
|
||||
const chrome = { theme: 'dark' };
|
||||
const dialogs = { open: false };
|
||||
const mounts = { TerminalLayerMount: null };
|
||||
const base: AppViewDomains = { vault, terminal, chrome, dialogs, mounts };
|
||||
// Same domain refs → equal (title churn must not replace these refs).
|
||||
assert.equal(appViewDomainsEqual(base, { vault, terminal, chrome, dialogs, mounts }), true);
|
||||
// Terminal domain identity change (structural session change) → unequal.
|
||||
assert.equal(
|
||||
appViewDomainsEqual(base, { vault, terminal: { sessions: [] }, chrome, dialogs, mounts }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
41
application/app/appViewDomains.ts
Normal file
41
application/app/appViewDomains.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Domain-scoped wiring for the main window shell.
|
||||
* App re-renders may rebuild the parent, but AppView only re-renders when one
|
||||
* of these domain slice identities changes.
|
||||
*/
|
||||
|
||||
export type AppViewDomainBag = Record<string, unknown>;
|
||||
|
||||
export type AppViewDomains = {
|
||||
/** Vault hosts/keys/notes/snippets (not shellHistory — that uses shellHistoryStore). */
|
||||
vault: AppViewDomainBag;
|
||||
/** Terminal sessions/workspaces/SFTP settings and terminal-layer handlers. */
|
||||
terminal: AppViewDomainBag;
|
||||
/** Top chrome: tabs, theme chrome, sync, host tree related. */
|
||||
chrome: AppViewDomainBag;
|
||||
/** Modals, queues, rename targets, quick switcher. */
|
||||
dialogs: AppViewDomainBag;
|
||||
/** Lazy mount components (stable module references). */
|
||||
mounts: AppViewDomainBag;
|
||||
};
|
||||
|
||||
export function mergeAppViewDomains(domains: AppViewDomains): AppViewDomainBag {
|
||||
return {
|
||||
...domains.vault,
|
||||
...domains.terminal,
|
||||
...domains.chrome,
|
||||
...domains.dialogs,
|
||||
...domains.mounts,
|
||||
};
|
||||
}
|
||||
|
||||
export function appViewDomainsEqual(
|
||||
prev: AppViewDomains,
|
||||
next: AppViewDomains,
|
||||
): boolean {
|
||||
return prev.vault === next.vault
|
||||
&& prev.terminal === next.terminal
|
||||
&& prev.chrome === next.chrome
|
||||
&& prev.dialogs === next.dialogs
|
||||
&& prev.mounts === next.mounts;
|
||||
}
|
||||
119
application/app/dedicatedResumeProgress.test.ts
Normal file
119
application/app/dedicatedResumeProgress.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../domain/models";
|
||||
import {
|
||||
canApplyDedicatedResumeProgress,
|
||||
createDedicatedResumeChildUpdateBatcher,
|
||||
createDedicatedResumeProgressBatcher,
|
||||
DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE,
|
||||
} from "./dedicatedResumeProgress";
|
||||
|
||||
test("deferred dedicated-resume progress cannot reopen a settled row", () => {
|
||||
for (const status of [
|
||||
"pausing",
|
||||
"paused",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"attention",
|
||||
"interrupted",
|
||||
] as const) {
|
||||
assert.equal(canApplyDedicatedResumeProgress(status), false, status);
|
||||
}
|
||||
for (const status of ["pending", "queued", "transferring"] as const) {
|
||||
assert.equal(canApplyDedicatedResumeProgress(status), true, status);
|
||||
}
|
||||
});
|
||||
|
||||
test("late animation-frame progress cannot revive any settled resume state", () => {
|
||||
const settledStatuses = [
|
||||
"pausing",
|
||||
"paused",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"attention",
|
||||
"interrupted",
|
||||
] as const;
|
||||
|
||||
for (const settledStatus of settledStatuses) {
|
||||
let status: TransferTask["status"] = "transferring";
|
||||
let scheduled: FrameRequestCallback | undefined;
|
||||
const applied: number[] = [];
|
||||
const batcher = createDedicatedResumeProgressBatcher<number>({
|
||||
requestFrame: (callback) => {
|
||||
scheduled = callback;
|
||||
return 41;
|
||||
},
|
||||
cancelFrame: () => undefined,
|
||||
canApply: () => canApplyDedicatedResumeProgress(status),
|
||||
apply: (progress) => applied.push(progress),
|
||||
});
|
||||
|
||||
batcher.push(7);
|
||||
status = settledStatus;
|
||||
scheduled?.(0);
|
||||
assert.deepEqual(applied, [], settledStatus);
|
||||
}
|
||||
});
|
||||
|
||||
test("finishing a resume flushes once and rejects raced or future progress", () => {
|
||||
let status: TransferTask["status"] = "transferring";
|
||||
let scheduled: FrameRequestCallback | undefined;
|
||||
const cancelledHandles: number[] = [];
|
||||
const applied: number[] = [];
|
||||
const batcher = createDedicatedResumeProgressBatcher<number>({
|
||||
requestFrame: (callback) => {
|
||||
scheduled = callback;
|
||||
return 73;
|
||||
},
|
||||
cancelFrame: (handle) => cancelledHandles.push(handle),
|
||||
canApply: () => canApplyDedicatedResumeProgress(status),
|
||||
apply: (progress) => applied.push(progress),
|
||||
});
|
||||
|
||||
batcher.push(11);
|
||||
batcher.push(12);
|
||||
batcher.finish();
|
||||
status = "completed";
|
||||
scheduled?.(0); // Simulate a frame already dequeued when it was cancelled.
|
||||
batcher.push(13);
|
||||
batcher.finish();
|
||||
|
||||
assert.deepEqual(applied, [12]);
|
||||
assert.deepEqual(cancelledHandles, [73]);
|
||||
});
|
||||
|
||||
test("50,000 retained child updates use a hard-bounded number of store scans", () => {
|
||||
const retained = new Set(Array.from({ length: 50_000 }, (_, index) => `child-${index}`));
|
||||
const batches: TransferTask[][] = [];
|
||||
const batcher = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 50_001,
|
||||
hasTask: (taskId) => retained.has(taskId),
|
||||
upsertTasks: (tasks) => batches.push([...tasks]),
|
||||
});
|
||||
|
||||
for (let index = 0; index < 50_000; index += 1) {
|
||||
const child = {
|
||||
id: `child-${index}`,
|
||||
status: "transferring",
|
||||
parentTaskId: "parent",
|
||||
} as TransferTask;
|
||||
batcher.push(child);
|
||||
batcher.push({ ...child, status: "completed" });
|
||||
}
|
||||
batcher.flush();
|
||||
|
||||
assert.ok(
|
||||
batches.length <= Math.ceil(50_000 / DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE),
|
||||
`expected bounded store scans, got ${batches.length}`,
|
||||
);
|
||||
const finalById = new Map<string, TransferTask>();
|
||||
for (const task of batches.flat()) finalById.set(task.id, task);
|
||||
assert.equal(finalById.size, 50_000);
|
||||
assert.ok([...finalById.values()].every((task) => task.status === "completed"));
|
||||
assert.ok(
|
||||
batches.flat().length <= 50_000 + batches.length,
|
||||
"a batch-boundary transition may repeat at most one child per store scan",
|
||||
);
|
||||
});
|
||||
100
application/app/dedicatedResumeProgress.ts
Normal file
100
application/app/dedicatedResumeProgress.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { TransferStatus, TransferTask } from "../../domain/models";
|
||||
|
||||
export const DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD = 4_096;
|
||||
export const DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE = 512;
|
||||
|
||||
export interface DedicatedResumeChildUpdateBatcher {
|
||||
push(task: TransferTask): void;
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
export interface DedicatedResumeProgressBatcher<T> {
|
||||
push(progress: T): void;
|
||||
finish(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A restarted directory can retain tens of thousands of exception rows. The
|
||||
* store intentionally performs full history compaction on each upsert, so
|
||||
* feeding it one child transition at a time becomes quadratic. Keep only the
|
||||
* latest state for each retained child and compact in fixed-size batches.
|
||||
*/
|
||||
export function createDedicatedResumeChildUpdateBatcher(deps: {
|
||||
getTaskCount: () => number;
|
||||
hasTask: (taskId: string) => boolean;
|
||||
upsertTasks: (tasks: readonly TransferTask[]) => void;
|
||||
}): DedicatedResumeChildUpdateBatcher {
|
||||
const pending = new Map<string, TransferTask>();
|
||||
const flush = () => {
|
||||
if (pending.size === 0) return;
|
||||
const batch = [...pending.values()];
|
||||
pending.clear();
|
||||
deps.upsertTasks(batch);
|
||||
};
|
||||
return {
|
||||
push(task) {
|
||||
const shouldBatch = !!task.parentTaskId
|
||||
&& deps.getTaskCount() >= DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD
|
||||
&& deps.hasTask(task.id);
|
||||
if (!shouldBatch) {
|
||||
deps.upsertTasks([task]);
|
||||
return;
|
||||
}
|
||||
pending.set(task.id, task);
|
||||
if (pending.size >= DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE) flush();
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
/** Only rows still owned by an active resume may accept a deferred rAF sample. */
|
||||
export function canApplyDedicatedResumeProgress(status: TransferStatus): boolean {
|
||||
return status === "pending" || status === "queued" || status === "transferring";
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce renderer progress without letting a callback outlive the resume
|
||||
* invocation that scheduled it. finish() preserves the newest sample once,
|
||||
* cancels the scheduled paint, and permanently rejects late callbacks.
|
||||
*/
|
||||
export function createDedicatedResumeProgressBatcher<T>(deps: {
|
||||
requestFrame: (callback: FrameRequestCallback) => number;
|
||||
cancelFrame: (handle: number) => void;
|
||||
canApply: () => boolean;
|
||||
apply: (progress: T) => void;
|
||||
}): DedicatedResumeProgressBatcher<T> {
|
||||
let pending: T | undefined;
|
||||
let frame: number | null = null;
|
||||
let finished = false;
|
||||
|
||||
const applyPending = () => {
|
||||
const progress = pending;
|
||||
pending = undefined;
|
||||
if (progress !== undefined && deps.canApply()) deps.apply(progress);
|
||||
};
|
||||
const flushFrame = () => {
|
||||
frame = null;
|
||||
if (finished) return;
|
||||
applyPending();
|
||||
};
|
||||
|
||||
return {
|
||||
push(progress) {
|
||||
if (finished) return;
|
||||
pending = progress;
|
||||
if (frame == null) frame = deps.requestFrame(flushFrame);
|
||||
},
|
||||
finish() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (frame != null) {
|
||||
deps.cancelFrame(frame);
|
||||
frame = null;
|
||||
}
|
||||
// Preserve the final durable checkpoint while the row is still active.
|
||||
// The caller can now publish its completed/failed/attention result with
|
||||
// no scheduled callback left that could overwrite the terminal state.
|
||||
applyPending();
|
||||
},
|
||||
};
|
||||
}
|
||||
210
application/app/hosts/ChromeHost.tsx
Normal file
210
application/app/hosts/ChromeHost.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { TERMINAL_THEMES } from '../../../infrastructure/config/terminalThemes';
|
||||
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
|
||||
import { useI18n } from '../../i18n/I18nProvider';
|
||||
import { useCustomThemes } from '../../state/customThemeStore';
|
||||
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
||||
import { toEditorTabId } from '../../state/activeTabStore';
|
||||
import {
|
||||
getSessionSnapshotActions,
|
||||
useSessionSnapshot,
|
||||
useSessionSnapshotActions,
|
||||
} from '../../state/sessionSnapshotStore';
|
||||
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
|
||||
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||
import {
|
||||
getTerminalSettingsActions,
|
||||
useTerminalSettingsStore,
|
||||
} from '../../state/terminalSettingsStore';
|
||||
import { usePluginViewTabs } from '../../state/pluginViewTabStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import {
|
||||
publishAppShellChrome,
|
||||
publishAppShellDomainSlice,
|
||||
} from '../appShellPropsStore';
|
||||
import {
|
||||
getThemeRuntimeActions,
|
||||
subscribeThemeRuntimeActions,
|
||||
} from '../themeRuntimeBridge';
|
||||
|
||||
const IS_MAC_CLIENT =
|
||||
typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
/**
|
||||
* Chrome island: TopTabs / active-tab chrome from settings chrome store,
|
||||
* appearance chrome, and selective session/vault snapshot fields. Assembles
|
||||
* chrome + shell chrome bags field-by-field — never spreads a prepared bag.
|
||||
*/
|
||||
export function ChromeHost() {
|
||||
const { t } = useI18n();
|
||||
const settingsChrome = useSettingsChromeStore();
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
const terminalSettings = useTerminalSettingsStore();
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
const pluginViewTabs = usePluginViewTabs();
|
||||
void pluginViewTabs;
|
||||
const customThemes = useCustomThemes();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
getAppHandlers,
|
||||
getAppHandlers,
|
||||
);
|
||||
const themeRuntime = useSyncExternalStore(
|
||||
subscribeThemeRuntimeActions,
|
||||
getThemeRuntimeActions,
|
||||
getThemeRuntimeActions,
|
||||
);
|
||||
|
||||
const orphanSessionsForShellRef = useRef(session.orphanSessions);
|
||||
const orphanSessionsForShell = retainStableSessionsIgnoringPresentation(
|
||||
orphanSessionsForShellRef.current,
|
||||
session.orphanSessions as never,
|
||||
);
|
||||
orphanSessionsForShellRef.current = orphanSessionsForShell as typeof session.orphanSessions;
|
||||
|
||||
const themeById = useMemo(
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
|
||||
const hostById = useMemo(
|
||||
() => new Map(vault.hosts.map((host) => [host.id, host])),
|
||||
[vault.hosts],
|
||||
);
|
||||
|
||||
const sessionById = useMemo(
|
||||
() => new Map(session.sessions.map((s) => [s.id, s])),
|
||||
[session.sessions],
|
||||
);
|
||||
|
||||
const workspaceById = useMemo(
|
||||
() => new Map(session.workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
[session.workspaces],
|
||||
);
|
||||
|
||||
const editorTabTopIds = useMemo(
|
||||
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
[editorTabs],
|
||||
);
|
||||
const pluginViewTabIds = useMemo(
|
||||
() => pluginViewTabs.map((tab) => tab.id),
|
||||
[pluginViewTabs],
|
||||
);
|
||||
const additionalWorkTabIds = useMemo(
|
||||
() => [...editorTabTopIds, ...pluginViewTabIds],
|
||||
[editorTabTopIds, pluginViewTabIds],
|
||||
);
|
||||
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => sessionActions?.getOrderedWorkTabs(additionalWorkTabIds) ?? ['vault'],
|
||||
[additionalWorkTabIds, sessionActions],
|
||||
);
|
||||
|
||||
const reorderWorkTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
) => {
|
||||
sessionActions?.reorderTabs(draggedId, targetId, position, additionalWorkTabIds);
|
||||
}, [additionalWorkTabIds, sessionActions]);
|
||||
|
||||
const chromeDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
closeLogView: sessionActions?.closeLogView,
|
||||
handleEndSessionDrag: handlers.handleEndSessionDrag,
|
||||
handleOpenQuickSwitcher: handlers.handleOpenQuickSwitcher,
|
||||
handleOpenSettings: handlers.handleOpenSettings,
|
||||
handleRootContextMenu: handlers.handleRootContextMenu,
|
||||
handleSyncNowManual: handlers.handleSyncNowManual,
|
||||
isMacClient: IS_MAC_CLIENT,
|
||||
logViews: session.logViews,
|
||||
openLogView: sessionActions?.openLogView,
|
||||
orderedTabsWithEditors,
|
||||
orphanSessions: orphanSessionsForShell,
|
||||
reorderWorkTabs,
|
||||
resetSessionRename: sessionActions?.resetSessionRename,
|
||||
resetWorkspaceRename: sessionActions?.resetWorkspaceRename,
|
||||
sessionRenameTarget: session.sessionRenameTarget,
|
||||
setActiveTabId: sessionActions?.setActiveTabId,
|
||||
startSessionRename: sessionActions?.startSessionRename,
|
||||
renameSessionInline: sessionActions?.renameSessionInline,
|
||||
startWorkspaceRename: sessionActions?.startWorkspaceRename,
|
||||
submitSessionRename: sessionActions?.submitSessionRename,
|
||||
submitWorkspaceRename: sessionActions?.submitWorkspaceRename,
|
||||
t,
|
||||
themeById,
|
||||
workspaceRenameTarget: session.workspaceRenameTarget,
|
||||
};
|
||||
}, [
|
||||
handlers,
|
||||
orderedTabsWithEditors,
|
||||
orphanSessionsForShell,
|
||||
reorderWorkTabs,
|
||||
session.logViews,
|
||||
session.sessionRenameTarget,
|
||||
session.workspaceRenameTarget,
|
||||
sessionActions,
|
||||
t,
|
||||
themeById,
|
||||
]);
|
||||
|
||||
// Call-time getters so chrome can publish before Publisher layout effects
|
||||
// register action slots — avoids undefined applyAppTheme/setActiveTabId on
|
||||
// the first Host publish (startup TypeError under StrictMode).
|
||||
const setActiveTabId = useCallback((id: string) => {
|
||||
getSessionSnapshotActions()?.setActiveTabId?.(id);
|
||||
}, []);
|
||||
const applyAppTheme = useCallback(() => {
|
||||
getTerminalSettingsActions()?.applyAppTheme?.();
|
||||
}, []);
|
||||
|
||||
const appShellChrome = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
// Theme runtime actions may still be null on the first paint before
|
||||
// TerminalHost registers them; wait so AppActiveTabChrome never mounts
|
||||
// with a missing resolveSessionAppearance / currentTerminalTheme.
|
||||
if (!themeRuntime?.currentTerminalTheme || !themeRuntime?.resolveFocusedAppearance) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
showSftpTab: settingsChrome.showSftpTab,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
currentTerminalTheme: themeRuntime.currentTerminalTheme,
|
||||
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
|
||||
editorTabs,
|
||||
logViews: session.logViews,
|
||||
resolveSessionAppearance: themeRuntime.resolveFocusedAppearance,
|
||||
t,
|
||||
};
|
||||
}, [
|
||||
applyAppTheme,
|
||||
editorTabs,
|
||||
handlers,
|
||||
hostById,
|
||||
session.logViews,
|
||||
sessionById,
|
||||
setActiveTabId,
|
||||
settingsChrome.showSftpTab,
|
||||
t,
|
||||
terminalSettings.followAppTerminalTheme,
|
||||
themeById,
|
||||
themeRuntime,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (chromeDomain) publishAppShellDomainSlice('chrome', chromeDomain);
|
||||
if (appShellChrome) publishAppShellChrome(appShellChrome as never);
|
||||
}, [appShellChrome, chromeDomain]);
|
||||
|
||||
return null;
|
||||
}
|
||||
107
application/app/hosts/DialogsHost.tsx
Normal file
107
application/app/hosts/DialogsHost.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { getHostSearchMatch } from '../../../lib/searchMatcher';
|
||||
import type { Host } from '../../../types';
|
||||
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
||||
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import {
|
||||
publishAppShellDomainSlice,
|
||||
publishAppShellOverlays,
|
||||
} from '../appShellPropsStore';
|
||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||
|
||||
const EMPTY_HOST_RESULTS: Host[] = [];
|
||||
|
||||
/**
|
||||
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
|
||||
* selective vault hosts subscription for quick-search results when open.
|
||||
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
|
||||
*/
|
||||
export function DialogsHost() {
|
||||
const local = useAppLocalUiStore();
|
||||
const vault = useVaultSnapshot();
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
getAppHandlers,
|
||||
getAppHandlers,
|
||||
);
|
||||
|
||||
const quickResults = useMemo(() => {
|
||||
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
|
||||
const term = local.quickSearch.trim();
|
||||
if (!term) return vault.hosts as Host[];
|
||||
return (vault.hosts as Host[])
|
||||
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
||||
.filter((entry) => entry.match.matched)
|
||||
.sort((left, right) => {
|
||||
if (left.match.score !== right.match.score) {
|
||||
return right.match.score - left.match.score;
|
||||
}
|
||||
return left.host.label.localeCompare(right.host.label);
|
||||
})
|
||||
.map((entry) => entry.host);
|
||||
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
|
||||
|
||||
const dialogsDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
addToWorkspaceDialog: local.addToWorkspaceDialog,
|
||||
clearAndRemoveSource: handlers.clearAndRemoveSource,
|
||||
clearAndRemoveSources: handlers.clearAndRemoveSources,
|
||||
editorTabs,
|
||||
emptyVaultConflict: local.emptyVaultConflict,
|
||||
handleHostConnectWithProtocolCheck: handlers.handleHostConnectWithProtocolCheck,
|
||||
handleKeyboardInteractiveCancel: handlers.handleKeyboardInteractiveCancel,
|
||||
handleKeyboardInteractiveSubmit: handlers.handleKeyboardInteractiveSubmit,
|
||||
handlePassphraseCancel: handlers.handlePassphraseCancel,
|
||||
handlePassphraseSkip: handlers.handlePassphraseSkip,
|
||||
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
|
||||
handleProtocolSelect: handlers.handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
|
||||
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
|
||||
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
|
||||
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
|
||||
passphraseQueue: local.passphraseQueue,
|
||||
protocolSelectHost: local.protocolSelectHost,
|
||||
quickResults,
|
||||
quickSearch: local.quickSearch,
|
||||
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
|
||||
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
|
||||
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost: handlers.setProtocolSelectHost,
|
||||
setQuickSearch: handlers.setQuickSearch,
|
||||
};
|
||||
}, [
|
||||
editorTabs,
|
||||
handlers,
|
||||
local.addToWorkspaceDialog,
|
||||
local.emptyVaultConflict,
|
||||
local.isCreateWorkspaceOpen,
|
||||
local.isQuickSwitcherOpen,
|
||||
local.keyboardInteractiveQueue,
|
||||
local.passphraseQueue,
|
||||
local.protocolSelectHost,
|
||||
local.quickSearch,
|
||||
quickResults,
|
||||
]);
|
||||
|
||||
const overlays = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
onAddKnownHost: handlers.handleAddKnownHost as (knownHost: never) => void,
|
||||
deleteHostConfirm: local.deleteHostConfirm,
|
||||
onCancelDeleteHost: handlers.handleCancelDeleteHost as () => void,
|
||||
onConfirmDeleteHost: handlers.handleConfirmDeleteHost as () => void,
|
||||
};
|
||||
}, [handlers, local.deleteHostConfirm]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (dialogsDomain) publishAppShellDomainSlice('dialogs', dialogsDomain);
|
||||
if (overlays) publishAppShellOverlays(overlays as never);
|
||||
}, [dialogsDomain, overlays]);
|
||||
|
||||
return null;
|
||||
}
|
||||
283
application/app/hosts/TerminalHost.tsx
Normal file
283
application/app/hosts/TerminalHost.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { TERMINAL_THEME_AUTO } from '../../../domain/terminalAppearance';
|
||||
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
|
||||
import { getAppSettingsRuntime } from '../../state/appRuntimeBridge';
|
||||
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
|
||||
import { useCustomThemes } from '../../state/customThemeStore';
|
||||
import {
|
||||
useSessionSnapshot,
|
||||
useSessionSnapshotActions,
|
||||
} from '../../state/sessionSnapshotStore';
|
||||
import {
|
||||
useSettingsChromeActions,
|
||||
useSettingsChromeStore,
|
||||
} from '../../state/settingsChromeStore';
|
||||
import {
|
||||
useTerminalSettingsActions,
|
||||
useTerminalSettingsStore,
|
||||
} from '../../state/terminalSettingsStore';
|
||||
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
|
||||
import {
|
||||
useVaultSnapshot,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||
import { registerThemeRuntimeActions } from '../themeRuntimeBridge';
|
||||
|
||||
/**
|
||||
* Terminal island: sessions from `sessionSnapshotStore`, terminal settings
|
||||
* from `terminalSettingsStore`, mutators via snapshot actions, theme runtime
|
||||
* owned here, glue handlers from the app handlers bridge. Assembles the full
|
||||
* terminal domain bag field-by-field — never spreads a prepared bag.
|
||||
*/
|
||||
export function TerminalHost() {
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
const terminalSettings = useTerminalSettingsStore();
|
||||
const terminalSettingsActions = useTerminalSettingsActions();
|
||||
const {
|
||||
followAppTerminalTheme,
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
} = terminalSettings;
|
||||
const settingsChrome = useSettingsChromeStore();
|
||||
const settingsChromeActions = useSettingsChromeActions();
|
||||
const appearance = useAppearanceChromeStore();
|
||||
const customThemes = useCustomThemes();
|
||||
const local = useAppLocalUiStore();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
getAppHandlers,
|
||||
getAppHandlers,
|
||||
);
|
||||
|
||||
// Call-time getters: SettingsPublisher registers the runtime in a layout
|
||||
// effect after this Host's first render. Capturing noop setters into
|
||||
// useThemeRuntime would permanently drop follow-app UI theme persistence.
|
||||
const setLightUiThemeId = useCallback((id: string) => {
|
||||
getAppSettingsRuntime()?.setLightUiThemeId?.(id);
|
||||
}, []);
|
||||
const setDarkUiThemeId = useCallback((id: string) => {
|
||||
getAppSettingsRuntime()?.setDarkUiThemeId?.(id);
|
||||
}, []);
|
||||
|
||||
const themeRuntime = useThemeRuntime({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
followAppTerminalTheme,
|
||||
resolvedTheme: settingsChrome.resolvedTheme,
|
||||
lightUiThemeId: settingsChrome.lightUiThemeId,
|
||||
darkUiThemeId: settingsChrome.darkUiThemeId,
|
||||
accentMode: appearance.accentMode,
|
||||
customAccent: appearance.customAccent,
|
||||
customThemes,
|
||||
setTheme: settingsChromeActions.setTheme,
|
||||
setLightUiThemeId,
|
||||
setDarkUiThemeId,
|
||||
});
|
||||
|
||||
const {
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
clearIntent: clearThemeIntent,
|
||||
settleManualIntent: settleManualThemeIntent,
|
||||
pickTheme: pickTerminalTheme,
|
||||
resolveFocusedAppearance,
|
||||
currentTerminalTheme,
|
||||
} = themeRuntime;
|
||||
|
||||
// Inject live accent into CSS vars without publishing accented theme identity
|
||||
// into the terminal domain bag (accent drag must not rebuild AppShell).
|
||||
useTerminalAppearanceInjection(accentedGlobalAppearance, {
|
||||
includeChromeSurfaces: followAppTerminalTheme,
|
||||
});
|
||||
|
||||
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
|
||||
useLayoutEffect(() => {
|
||||
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
|
||||
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
|
||||
clearThemeIntent();
|
||||
}, [followAppTerminalTheme, clearThemeIntent]);
|
||||
|
||||
// Bridge exposes the stable base theme only — ChromeHost must not republish
|
||||
// when accentedGlobalAppearance identity churns during color-picker drag.
|
||||
const themeBridgeActions = useMemo(() => ({
|
||||
clearThemeIntent,
|
||||
settleManualThemeIntent,
|
||||
pickTerminalTheme,
|
||||
resolveFocusedAppearance: resolveFocusedAppearance as (...args: never[]) => unknown,
|
||||
currentTerminalTheme,
|
||||
globalAppearance,
|
||||
}), [
|
||||
clearThemeIntent,
|
||||
currentTerminalTheme,
|
||||
globalAppearance,
|
||||
pickTerminalTheme,
|
||||
resolveFocusedAppearance,
|
||||
settleManualThemeIntent,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerThemeRuntimeActions(themeBridgeActions);
|
||||
return () => {
|
||||
registerThemeRuntimeActions(null);
|
||||
};
|
||||
}, [themeBridgeActions]);
|
||||
|
||||
const sessionsForShellRef = useRef(session.sessions);
|
||||
const sessionsForShell = retainStableSessionsIgnoringPresentation(
|
||||
sessionsForShellRef.current,
|
||||
session.sessions as never,
|
||||
);
|
||||
sessionsForShellRef.current = sessionsForShell as typeof session.sessions;
|
||||
|
||||
const hostById = useMemo(
|
||||
() => new Map(vault.hosts.map((host) => [host.id, host])),
|
||||
[vault.hosts],
|
||||
);
|
||||
|
||||
const terminalHosts = useMemo(
|
||||
() => (
|
||||
local.ephemeralHosts.length > 0
|
||||
? [...vault.hosts, ...local.ephemeralHosts]
|
||||
: vault.hosts
|
||||
),
|
||||
[local.ephemeralHosts, vault.hosts],
|
||||
);
|
||||
|
||||
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
|
||||
// Persist the default theme for ephemeral/manual hosts. Mode overrides
|
||||
// reset to auto so the chosen theme becomes the new baseline for the
|
||||
// current resolved UI mode (same behavior as pre-Host App).
|
||||
terminalSettingsActions?.setTerminalThemeId(themeId);
|
||||
if (settingsChrome.resolvedTheme === 'dark') {
|
||||
terminalSettingsActions?.setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
|
||||
} else {
|
||||
terminalSettingsActions?.setTerminalThemeLightId(TERMINAL_THEME_AUTO);
|
||||
}
|
||||
}, [settingsChrome.resolvedTheme, terminalSettingsActions]);
|
||||
|
||||
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
|
||||
pickTerminalTheme(themeId);
|
||||
}, [pickTerminalTheme]);
|
||||
|
||||
const terminalDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
|
||||
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
|
||||
closeSession: sessionActions?.closeSession,
|
||||
closeTabsBatch: handlers.closeTabsBatch,
|
||||
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
|
||||
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
|
||||
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
|
||||
duplicateSessionWithCurrentShell: handlers.duplicateSessionWithCurrentShell,
|
||||
closeWorkspace: sessionActions?.closeWorkspace,
|
||||
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
|
||||
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
|
||||
createWorkspaceWithHosts: handlers.createWorkspaceWithHosts,
|
||||
currentTerminalTheme,
|
||||
draggingSessionId: session.draggingSessionId,
|
||||
editorWordWrap: terminalSettings.editorWordWrap,
|
||||
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
|
||||
clearThemeIntent,
|
||||
settleManualThemeIntent,
|
||||
pickTerminalTheme,
|
||||
resolveSessionAppearance: resolveFocusedAppearance,
|
||||
handleConnectSerial: handlers.handleConnectSerial,
|
||||
handleConnectToHost: handlers.handleConnectToHost,
|
||||
handleCreateLocalTerminal: handlers.handleCreateLocalTerminal,
|
||||
handleDefaultTerminalThemeChange,
|
||||
handleFollowAppTerminalThemeChange,
|
||||
handleHotkeyAction: handlers.handleHotkeyAction,
|
||||
handleSessionStatusChange: handlers.handleSessionStatusChange,
|
||||
handleTerminalDataCapture: handlers.handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal: handlers.handleUpdateHostFromTerminal,
|
||||
hostById,
|
||||
terminalHosts,
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
portForwardingRules: local.portForwardingRules,
|
||||
removeSessionFromWorkspace: sessionActions?.removeSessionFromWorkspace,
|
||||
reorderWorkspaceSessions: sessionActions?.reorderWorkspaceSessions,
|
||||
runSnippet: handlers.runSnippet,
|
||||
sessionLogsDir: terminalSettings.sessionLogsDir,
|
||||
sessionLogsEnabled: terminalSettings.sessionLogsEnabled,
|
||||
sessionLogsFormat: terminalSettings.sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled: terminalSettings.sessionLogsTimestampsEnabled,
|
||||
sessions: sessionsForShell,
|
||||
setDraggingSessionId: sessionActions?.setDraggingSessionId,
|
||||
setEditorWordWrap: terminalSettingsActions?.setEditorWordWrap,
|
||||
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
|
||||
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
|
||||
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
|
||||
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
|
||||
sftpAutoSync: terminalSettings.sftpAutoSync,
|
||||
sftpDefaultViewMode: terminalSettings.sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior: terminalSettings.sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles: terminalSettings.sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload: terminalSettings.sftpUseCompressedUpload,
|
||||
splitSessionWithCurrentShell: handlers.splitSessionWithCurrentShell,
|
||||
sshDebugLogsEnabled: terminalSettings.sshDebugLogsEnabled,
|
||||
terminalFontFamilyId: terminalSettings.terminalFontFamilyId,
|
||||
terminalFontSize: terminalSettings.terminalFontSize,
|
||||
terminalSettings: terminalSettings.terminalSettings,
|
||||
terminalThemeId: terminalSettings.terminalThemeId,
|
||||
toggleBroadcast: sessionActions?.toggleBroadcast,
|
||||
toggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
|
||||
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef: handlers.toggleSidePanelRef,
|
||||
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
|
||||
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
|
||||
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
|
||||
updateHostDistro: handlers.updateTerminalHostDistro,
|
||||
updateSplitSizes: sessionActions?.updateSplitSizes,
|
||||
updateSessionFontSize: sessionActions?.updateSessionFontSize,
|
||||
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
|
||||
updateSessionDynamicTitle: sessionActions?.updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider: sessionActions?.updateSessionCodingCliProvider,
|
||||
updateTerminalSetting: terminalSettingsActions?.updateTerminalSetting,
|
||||
workspaces: session.workspaces,
|
||||
};
|
||||
}, [
|
||||
clearThemeIntent,
|
||||
currentTerminalTheme,
|
||||
handleDefaultTerminalThemeChange,
|
||||
handleFollowAppTerminalThemeChange,
|
||||
handlers,
|
||||
hostById,
|
||||
local.openNoteRequest,
|
||||
local.portForwardingRules,
|
||||
pickTerminalTheme,
|
||||
resolveFocusedAppearance,
|
||||
session.draggingSessionId,
|
||||
session.workspaces,
|
||||
sessionActions,
|
||||
sessionsForShell,
|
||||
settleManualThemeIntent,
|
||||
terminalHosts,
|
||||
terminalSettings,
|
||||
terminalSettingsActions,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (terminalDomain) publishAppShellDomainSlice('terminal', terminalDomain);
|
||||
}, [terminalDomain]);
|
||||
|
||||
return null;
|
||||
}
|
||||
105
application/app/hosts/VaultHost.tsx
Normal file
105
application/app/hosts/VaultHost.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
|
||||
import {
|
||||
useVaultSnapshot,
|
||||
useVaultSnapshotActions,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||
import { APP_MOUNTS_DOMAIN } from './mountsDomain';
|
||||
|
||||
/**
|
||||
* Vault island: catalog from `vaultSnapshotStore`, glue handlers from the
|
||||
* app handlers bridge, local vault UI from `appLocalUiStore`. Assembles the
|
||||
* full vault domain bag field-by-field — never spreads a prepared bag from
|
||||
* AppSideEffects.
|
||||
*/
|
||||
export function VaultHost() {
|
||||
const vault = useVaultSnapshot();
|
||||
const actions = useVaultSnapshotActions();
|
||||
const local = useAppLocalUiStore();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
getAppHandlers,
|
||||
getAppHandlers,
|
||||
);
|
||||
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(vault.knownHosts as never) ?? [],
|
||||
[vault.knownHosts],
|
||||
);
|
||||
|
||||
const vaultDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
addShellHistoryEntry: actions?.addShellHistoryEntry,
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
convertKnownHostToHost: actions?.convertKnownHostToHost,
|
||||
customGroups: vault.customGroups,
|
||||
deepLinkHostDraft: local.deepLinkHostDraft,
|
||||
effectiveKnownHosts,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
handleAddKnownHost: handlers.handleAddKnownHost,
|
||||
handleDeleteHost: handlers.handleDeleteHost,
|
||||
handleOpenHostFromVaultNote: handlers.handleOpenHostFromVaultNote,
|
||||
handleOpenVaultHostFromChat: handlers.handleOpenVaultHostFromChat,
|
||||
handleOpenVaultNoteFromChat: handlers.handleOpenVaultNoteFromChat,
|
||||
handleOpenVaultSectionFromChat: handlers.handleOpenVaultSectionFromChat,
|
||||
handleOpenVaultSnippetFromChat: handlers.handleOpenVaultSnippetFromChat,
|
||||
hosts: vault.hosts,
|
||||
identities: vault.identities,
|
||||
importOrReuseKey: actions?.importOrReuseKey,
|
||||
keys: vault.keys,
|
||||
managedSources: vault.managedSources,
|
||||
navigateToSection: local.navigateToSection,
|
||||
proxyProfiles: vault.proxyProfiles,
|
||||
readPersistedHosts: actions?.readPersistedHosts,
|
||||
readPersistedManagedSources: actions?.readPersistedManagedSources,
|
||||
setDeepLinkHostDraft: handlers.setDeepLinkHostDraft,
|
||||
setNavigateToSection: handlers.setNavigateToSection,
|
||||
setVaultFocusRequest: handlers.setVaultFocusRequest,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
snippets: vault.snippets,
|
||||
unmanageSource: handlers.unmanageSource,
|
||||
updateCustomGroups: actions?.updateCustomGroups,
|
||||
updateGroupConfigs: actions?.updateGroupConfigs,
|
||||
updateHosts: actions?.updateHosts,
|
||||
updateIdentities: actions?.updateIdentities,
|
||||
updateKeys: actions?.updateKeys,
|
||||
updateKnownHosts: actions?.updateKnownHosts,
|
||||
updateManagedSources: actions?.updateManagedSources,
|
||||
updateProxyProfiles: actions?.updateProxyProfiles,
|
||||
updateSnippetPackages: actions?.updateSnippetPackages,
|
||||
updateSnippets: actions?.updateSnippets,
|
||||
vaultFocusRequest: local.vaultFocusRequest,
|
||||
};
|
||||
}, [
|
||||
actions,
|
||||
effectiveKnownHosts,
|
||||
handlers,
|
||||
local.deepLinkHostDraft,
|
||||
local.navigateToSection,
|
||||
local.vaultFocusRequest,
|
||||
vault.customGroups,
|
||||
vault.groupConfigs,
|
||||
vault.hosts,
|
||||
vault.identities,
|
||||
vault.keys,
|
||||
vault.managedSources,
|
||||
vault.proxyProfiles,
|
||||
vault.snippetPackages,
|
||||
vault.snippets,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (vaultDomain) publishAppShellDomainSlice('vault', vaultDomain);
|
||||
publishAppShellDomainSlice('mounts', APP_MOUNTS_DOMAIN);
|
||||
}, [vaultDomain]);
|
||||
|
||||
return null;
|
||||
}
|
||||
9
application/app/hosts/mountsDomain.ts
Normal file
9
application/app/hosts/mountsDomain.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from '../AppMounts';
|
||||
|
||||
/** Lazy mount wrappers — stable module identity for the app lifetime. */
|
||||
export const APP_MOUNTS_DOMAIN = Object.freeze({
|
||||
VaultViewContainer,
|
||||
SftpViewMount,
|
||||
TerminalLayerMount,
|
||||
LogViewWrapper,
|
||||
});
|
||||
97
application/app/keyboardInteractiveScope.test.ts
Normal file
97
application/app/keyboardInteractiveScope.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
removeKeyboardInteractiveRequest,
|
||||
shouldQueueKeyboardInteractiveRequest,
|
||||
} from "./useAppStartupEffects.ts";
|
||||
import {
|
||||
clearTerminalBootEpoch,
|
||||
setTerminalBootEpoch,
|
||||
} from "../../domain/terminalBootEpoch.ts";
|
||||
|
||||
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
||||
|
||||
test("terminal-scoped keyboard-interactive requests are limited to owned sessions", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "foreign-terminal" }, sessions),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "disconnected" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
|
||||
setTerminalBootEpoch("terminal-1", 3);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
clearTerminalBootEpoch("terminal-1");
|
||||
});
|
||||
|
||||
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "tunnel-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled peer windows still queue sender-targeted external keyboard-interactive requests", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled peer windows can still queue owned terminal keyboard-interactive requests", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("legacy unscoped keyboard-interactive requests remain visible", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ sessionId: "legacy-conn" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("cancelled keyboard-interactive requests are removed from the renderer queue", () => {
|
||||
const queue = [{ requestId: "keep" }, { requestId: "cancel" }];
|
||||
assert.deepEqual(removeKeyboardInteractiveRequest(queue, "cancel"), [{ requestId: "keep" }]);
|
||||
});
|
||||
53
application/app/publishers/AppLockRuntimePublisher.tsx
Normal file
53
application/app/publishers/AppLockRuntimePublisher.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useLayoutEffect, useMemo, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
AppLockChromeContext,
|
||||
registerAppAppLockRuntime,
|
||||
type AppAppLockRuntime,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
|
||||
export type AppLockRuntimePublisherProps = {
|
||||
/** App-lock runtime owned by `AppLockGate` (index.tsx render prop). */
|
||||
appLock: AppAppLockRuntime;
|
||||
/** `settings.appLockSettings.enabled` from the gate's settings instance. */
|
||||
appLockEnabled: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes the gate-owned app-lock runtime the same way SettingsPublisher
|
||||
* publishes settings: the full runtime goes on the `appRuntimeBridge` slot for
|
||||
* imperative callers (`getAppAppLockRuntime()`), and a narrow memoized chrome
|
||||
* slice goes on context so TopTabs / AppSideEffects re-render only when the
|
||||
* lock state actually changes — the full runtime changes identity on every
|
||||
* gate render.
|
||||
*/
|
||||
export function AppLockRuntimePublisher({
|
||||
appLock,
|
||||
appLockEnabled,
|
||||
children,
|
||||
}: AppLockRuntimePublisherProps) {
|
||||
useLayoutEffect(() => {
|
||||
registerAppAppLockRuntime(appLock);
|
||||
}, [appLock]);
|
||||
|
||||
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppAppLockRuntime(null);
|
||||
}, []);
|
||||
|
||||
const chrome = useMemo(
|
||||
() => ({
|
||||
appLockEnabled,
|
||||
locked: appLock.locked,
|
||||
initialized: appLock.initialized,
|
||||
}),
|
||||
[appLockEnabled, appLock.locked, appLock.initialized],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLockChromeContext.Provider value={chrome}>
|
||||
{children}
|
||||
</AppLockChromeContext.Provider>
|
||||
);
|
||||
}
|
||||
226
application/app/publishers/SessionPublisher.tsx
Normal file
226
application/app/publishers/SessionPublisher.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useLayoutEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
AppSessionRuntimeContext,
|
||||
registerAppSessionRuntime,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
import {
|
||||
publishSessionSnapshot,
|
||||
registerSessionSnapshotActions,
|
||||
} from '../../state/sessionSnapshotStore';
|
||||
import { useSessionState } from '../../state/useSessionState';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type SessionPublisherProps = {
|
||||
/** Peer session windows must not write the main window's restore record. */
|
||||
persistSessionRestore: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns `useSessionState` and publishes it the same three ways `VaultPublisher`
|
||||
* publishes the vault: catalog into `sessionSnapshotStore`, mutators into its
|
||||
* action slot, and the whole runtime onto `appRuntimeBridge` for App.
|
||||
*/
|
||||
export function SessionPublisher({ persistSessionRestore, children }: SessionPublisherProps) {
|
||||
const session = useSessionState({ persistSessionRestore });
|
||||
const {
|
||||
sessions,
|
||||
orphanSessions,
|
||||
workspaces,
|
||||
logViews,
|
||||
draggingSessionId,
|
||||
sessionRenameTarget,
|
||||
workspaceRenameTarget,
|
||||
setActiveTabId,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
setDraggingSessionId,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
startWorkspaceRename,
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
removeSessionFromWorkspace,
|
||||
setWorkspaceFocusedSession,
|
||||
toggleWorkspaceViewMode,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
updateSessionRestoreCwd,
|
||||
getSessionRestoreCwd,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider,
|
||||
} = session;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerAppSessionRuntime(session);
|
||||
}, [session]);
|
||||
|
||||
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppSessionRuntime(null);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishSessionSnapshot({
|
||||
sessions,
|
||||
orphanSessions,
|
||||
workspaces,
|
||||
logViews,
|
||||
draggingSessionId,
|
||||
sessionRenameTarget,
|
||||
workspaceRenameTarget,
|
||||
});
|
||||
}, [
|
||||
draggingSessionId,
|
||||
logViews,
|
||||
orphanSessions,
|
||||
sessionRenameTarget,
|
||||
sessions,
|
||||
workspaceRenameTarget,
|
||||
workspaces,
|
||||
]);
|
||||
|
||||
const sessionActions = useMemo(() => ({
|
||||
setActiveTabId,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
setDraggingSessionId,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
startWorkspaceRename,
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
removeSessionFromWorkspace,
|
||||
setWorkspaceFocusedSession,
|
||||
toggleWorkspaceViewMode,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
updateSessionRestoreCwd,
|
||||
getSessionRestoreCwd,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider,
|
||||
}), [
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride,
|
||||
closeLogView,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
connectToHost,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
createSessionFromCloneSource,
|
||||
createWorkspaceFromSessions,
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceWithHosts,
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
removeSessionFromWorkspace,
|
||||
renameSessionInline,
|
||||
reorderTabs,
|
||||
reorderWorkspaceSessions,
|
||||
resetSessionRename,
|
||||
resetWorkspaceRename,
|
||||
runSnippet,
|
||||
setActiveTabId,
|
||||
setDraggingSessionId,
|
||||
setWorkspaceFocusedSession,
|
||||
splitSession,
|
||||
startSessionRename,
|
||||
startWorkspaceRename,
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionFontSize,
|
||||
updateSessionRestoreCwd,
|
||||
updateSessionStatus,
|
||||
updateSplitSizes,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerSessionSnapshotActions(sessionActions);
|
||||
return () => {
|
||||
registerSessionSnapshotActions(null);
|
||||
};
|
||||
}, [sessionActions]);
|
||||
|
||||
return (
|
||||
<AppSessionRuntimeContext.Provider value={session}>
|
||||
{children}
|
||||
</AppSessionRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
43
application/app/publishers/SettingsPublisher.tsx
Normal file
43
application/app/publishers/SettingsPublisher.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useLayoutEffect, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
AppSettingsRuntimeContext,
|
||||
registerAppSettingsRuntime,
|
||||
type AppSettingsRuntime,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
|
||||
export type SettingsPublisherProps = {
|
||||
/**
|
||||
* Pre-built settings runtime owned by an ancestor. `AppLockGate` (index.tsx)
|
||||
* owns `useSettingsState` so the lock overlay can render before app children
|
||||
* mount; the publisher only binds the runtime slot and context.
|
||||
*/
|
||||
settings: AppSettingsRuntime;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes the gate-owned settings runtime the same way `VaultPublisher` /
|
||||
* `SessionPublisher` hand over their runtimes: a context for render-time reads
|
||||
* and the `appRuntimeBridge` slot for imperative callers.
|
||||
*
|
||||
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
|
||||
* happens inside `useSettingsState`, so this publisher only relocates the
|
||||
* binding out of the component that also builds the shell's domain bags.
|
||||
*/
|
||||
export function SettingsPublisher({ settings, children }: SettingsPublisherProps) {
|
||||
useLayoutEffect(() => {
|
||||
registerAppSettingsRuntime(settings);
|
||||
}, [settings]);
|
||||
|
||||
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppSettingsRuntime(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppSettingsRuntimeContext.Provider value={settings}>
|
||||
{children}
|
||||
</AppSettingsRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
200
application/app/publishers/VaultPublisher.tsx
Normal file
200
application/app/publishers/VaultPublisher.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
AppVaultRuntimeContext,
|
||||
registerAppVaultRuntime,
|
||||
type AppVaultContextValue,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
import {
|
||||
publishVaultSnapshot,
|
||||
registerVaultSnapshotActions,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { useVaultState } from '../../state/useVaultState';
|
||||
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
|
||||
|
||||
export type VaultPublisherProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
function vaultContextValuesEqual(
|
||||
prev: AppVaultContextValue,
|
||||
next: AppVaultContextValue,
|
||||
): boolean {
|
||||
const keys = Object.keys(next) as Array<keyof AppVaultContextValue>;
|
||||
return keys.every((key) => prev[key] === next[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns `useVaultState` and publishes it three ways: the catalog into
|
||||
* `vaultSnapshotStore` for shell surfaces that subscribe to a slice, the
|
||||
* mutators into the same store's action slot, and a **catalog-only** runtime
|
||||
* onto `AppVaultRuntimeContext` for App.
|
||||
*
|
||||
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are intentionally
|
||||
* absent from the context value: they churn on a different cadence and already
|
||||
* fan out through dedicated stores. Including them would force App (the
|
||||
* domain-bag builder) to re-render on every note edit or session log append.
|
||||
* Imperative callers that still need the full hook return use
|
||||
* `getAppVaultRuntime()`.
|
||||
*/
|
||||
export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
const vault = useVaultState();
|
||||
const {
|
||||
isInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
customGroups,
|
||||
knownHosts,
|
||||
managedSources,
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateGroupConfigs,
|
||||
convertKnownHostToHost,
|
||||
readPersistedHosts,
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
} = vault;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerAppVaultRuntime(vault);
|
||||
}, [vault]);
|
||||
|
||||
// Only a real unmount clears the slot. A re-render re-registers through the
|
||||
// effect above, and StrictMode's simulated remount re-runs both.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppVaultRuntime(null);
|
||||
}, []);
|
||||
|
||||
// useVaultState decrypts hosts/keys before it reads known hosts, so the state
|
||||
// is briefly empty at boot even when storage has entries. Publish the same
|
||||
// storage fallback App uses so a subscriber connecting during that window
|
||||
// does not re-prompt for a fingerprint it already trusts.
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishVaultSnapshot({
|
||||
isVaultInitialized: isInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
customGroups,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
managedSources,
|
||||
groupConfigs,
|
||||
});
|
||||
}, [
|
||||
customGroups,
|
||||
effectiveKnownHosts,
|
||||
groupConfigs,
|
||||
hosts,
|
||||
identities,
|
||||
isInitialized,
|
||||
keys,
|
||||
managedSources,
|
||||
proxyProfiles,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerVaultSnapshotActions({
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateGroupConfigs,
|
||||
convertKnownHostToHost,
|
||||
readPersistedHosts,
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
});
|
||||
return () => {
|
||||
registerVaultSnapshotActions(null);
|
||||
};
|
||||
}, [
|
||||
addShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
convertKnownHostToHost,
|
||||
importOrReuseKey,
|
||||
readPersistedHosts,
|
||||
readPersistedManagedSources,
|
||||
removeShellHistoryEntry,
|
||||
updateCustomGroups,
|
||||
updateGroupConfigs,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
updateHosts,
|
||||
updateIdentities,
|
||||
updateKeys,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateProxyProfiles,
|
||||
updateSnippetPackages,
|
||||
updateSnippets,
|
||||
]);
|
||||
|
||||
// Strip high-churn fields, then retain the previous object identity when only
|
||||
// those fields (or exportData) changed so React context consumers stay quiet.
|
||||
const vaultForAppRef = useRef<AppVaultContextValue | null>(null);
|
||||
const vaultForApp = useMemo((): AppVaultContextValue => {
|
||||
const {
|
||||
notes: _notes,
|
||||
noteGroups: _noteGroups,
|
||||
connectionLogs: _connectionLogs,
|
||||
shellHistory: _shellHistory,
|
||||
exportData: _exportData,
|
||||
...catalog
|
||||
} = vault;
|
||||
const prev = vaultForAppRef.current;
|
||||
if (prev && vaultContextValuesEqual(prev, catalog)) {
|
||||
return prev;
|
||||
}
|
||||
vaultForAppRef.current = catalog;
|
||||
return catalog;
|
||||
}, [vault]);
|
||||
|
||||
return (
|
||||
<AppVaultRuntimeContext.Provider value={vaultForApp}>
|
||||
{children}
|
||||
</AppVaultRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
275
application/app/strictModeIdempotency.test.ts
Normal file
275
application/app/strictModeIdempotency.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
|
||||
const appSideEffectsSource = readFileSync(
|
||||
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const indexSource = readFileSync(new URL('../../index.tsx', import.meta.url), 'utf8');
|
||||
const startupEffectsSource = readFileSync(
|
||||
new URL('./useAppStartupEffects.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const updateCheckSource = readFileSync(
|
||||
new URL('../state/useUpdateCheck.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const portForwardingAutoStartSource = readFileSync(
|
||||
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const appLockBridgeSource = readFileSync(
|
||||
new URL('../state/useAppLockBridge.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('every renderer root mounts under StrictMode', () => {
|
||||
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
|
||||
|
||||
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
|
||||
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
|
||||
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < renderCalls.length; index += 1) {
|
||||
const renderAt = indexSource.indexOf('root.render(', cursor);
|
||||
assert.notEqual(renderAt, -1);
|
||||
const opener = indexSource.slice(renderAt, renderAt + 60);
|
||||
assert.match(opener, /root\.render\(\s*<StrictMode>/, `root.render #${index + 1} lacks StrictMode`);
|
||||
cursor = renderAt + 'root.render('.length;
|
||||
}
|
||||
});
|
||||
|
||||
test('clone-session payload is consumed once even if the effect re-runs', () => {
|
||||
const effectStart = appSideEffectsSource.indexOf('consumedNewWindowSessionRef');
|
||||
assert.notEqual(effectStart, -1, 'clone-session effect must latch on payload identity');
|
||||
const effectEnd = appSideEffectsSource.indexOf(
|
||||
'}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);',
|
||||
effectStart,
|
||||
);
|
||||
assert.notEqual(effectEnd, -1);
|
||||
const body = appSideEffectsSource.slice(effectStart, effectEnd);
|
||||
|
||||
// A ref comparison is required: clearing the state only lands on the next
|
||||
// render, so a re-invoked effect still closes over the same payload.
|
||||
assert.match(body, /if \(consumedNewWindowSessionRef\.current === pendingNewWindowSession\) return;/);
|
||||
assert.ok(
|
||||
body.indexOf('consumedNewWindowSessionRef.current = pending')
|
||||
< body.indexOf('createSessionFromCloneSource(pending.sourceSession'),
|
||||
'payload must be marked consumed before the clone is created',
|
||||
);
|
||||
});
|
||||
|
||||
test('rendererReady is notified once per renderer process', () => {
|
||||
assert.match(appLockBridgeSource, /^let rendererReadySent = false;$/m);
|
||||
const guardAt = appLockBridgeSource.indexOf('if (rendererReadySent) return;');
|
||||
assert.notEqual(guardAt, -1);
|
||||
const guarded = appLockBridgeSource.slice(guardAt, guardAt + 200);
|
||||
assert.match(guarded, /rendererReadySent = true;/);
|
||||
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
|
||||
assert.ok(
|
||||
guarded.indexOf('rendererReadySent = true;')
|
||||
< guarded.indexOf('netcattyBridge.get()?.rendererReady?.()'),
|
||||
'the latch must be set before the IPC call so a re-entrant effect is blocked',
|
||||
);
|
||||
});
|
||||
|
||||
test('update-available toast latches on the release version', () => {
|
||||
const latchAt = startupEffectsSource.indexOf('toastedUpdateVersionRef');
|
||||
assert.notEqual(latchAt, -1);
|
||||
assert.match(startupEffectsSource, /const toastedUpdateVersionRef = useRef<string \| null>\(null\)/);
|
||||
assert.match(startupEffectsSource, /if \(toastedUpdateVersionRef\.current === version\) return;/);
|
||||
|
||||
const guardAt = startupEffectsSource.indexOf('if (toastedUpdateVersionRef.current === version) return;');
|
||||
const toastAt = startupEffectsSource.indexOf('toast.info(', guardAt);
|
||||
assert.notEqual(toastAt, -1);
|
||||
assert.ok(guardAt < toastAt, 'the version latch must gate the toast call');
|
||||
});
|
||||
|
||||
test('port-forward auto-start runs once across a StrictMode double effect', () => {
|
||||
assert.match(
|
||||
portForwardingAutoStartSource,
|
||||
/const autoStartExecutedRef = useRef\(false\);/,
|
||||
'the launch auto-start needs a module-render latch, not just an effect dep list',
|
||||
);
|
||||
|
||||
const effectStart = portForwardingAutoStartSource.indexOf('if (autoStartExecutedRef.current) return;');
|
||||
assert.notEqual(effectStart, -1, 'the effect must bail out when the latch is already set');
|
||||
const effectEnd = portForwardingAutoStartSource.indexOf(
|
||||
'}, [\n enabled,\n isVaultInitialized,\n runAutoStart,\n ]);',
|
||||
effectStart,
|
||||
);
|
||||
assert.notEqual(effectEnd, -1, 'auto-start effect dep list moved; update this contract');
|
||||
const body = portForwardingAutoStartSource.slice(effectStart, effectEnd);
|
||||
|
||||
// StrictMode invokes the effect twice with the same render's closure, so the
|
||||
// latch has to be written before the async run is kicked off — awaiting or
|
||||
// deferring the write would let the second invoke start a duplicate tunnel.
|
||||
const latchAt = body.indexOf('autoStartExecutedRef.current = true;');
|
||||
const runAt = body.indexOf('void runAutoStart();');
|
||||
assert.notEqual(latchAt, -1);
|
||||
assert.notEqual(runAt, -1);
|
||||
assert.ok(latchAt < runAt, 'the latch must be set before runAutoStart() is called');
|
||||
|
||||
// The vault gate must also sit before the latch: latching on a pre-hydration
|
||||
// invoke would permanently suppress the real auto-start.
|
||||
assert.ok(
|
||||
body.indexOf('if (!isVaultInitialized) return;') < latchAt,
|
||||
'the vault gate must precede the latch write',
|
||||
);
|
||||
});
|
||||
|
||||
test('cancelled startup update check resets its latch instead of skipping forever', () => {
|
||||
const scheduleAt = updateCheckSource.indexOf('let checkArmed = true;');
|
||||
assert.notEqual(scheduleAt, -1);
|
||||
const tail = updateCheckSource.slice(scheduleAt);
|
||||
|
||||
// The latch is only meaningful once the timer actually fires; a cleanup that
|
||||
// cancels it beforehand must let the next effect schedule again.
|
||||
assert.match(tail, /startupCheckTimeoutRef\.current = setTimeout\(async \(\) => \{\s*\n\s*checkArmed = false;/);
|
||||
assert.match(tail, /if \(checkArmed\) \{\s*\n\s*hasCheckedOnStartupRef\.current = false;\s*\n\s*\}/);
|
||||
});
|
||||
|
||||
test('terminal popup config survives StrictMode unsubscribe/resubscribe', () => {
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload.cjs', import.meta.url), 'utf8');
|
||||
const apiSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(preloadSource, /lastPayload:\s*null/);
|
||||
assert.match(
|
||||
preloadSource,
|
||||
/terminalPopupConfigState\.lastPayload = payload/,
|
||||
'incoming popup config must be retained beyond the one-shot pending slot',
|
||||
);
|
||||
|
||||
const subscribeAt = apiSource.indexOf('onTerminalPopupConfig:');
|
||||
assert.notEqual(subscribeAt, -1);
|
||||
const subscribe = apiSource.slice(subscribeAt, subscribeAt + 700);
|
||||
assert.match(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.pending \?\? terminalPopupConfigState\.lastPayload/,
|
||||
'resubscribe must replay lastPayload after pending was drained',
|
||||
);
|
||||
assert.match(subscribe, /terminalPopupConfigState\.pending = null/);
|
||||
assert.doesNotMatch(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.lastPayload = null/,
|
||||
'StrictMode remount must not clear lastPayload on subscribe',
|
||||
);
|
||||
});
|
||||
|
||||
test('vault init cancels the superseded StrictMode effect before publishing ready', () => {
|
||||
const vaultSource = readFileSync(
|
||||
new URL('../state/useVaultState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
'only the surviving init may mark the vault ready',
|
||||
);
|
||||
assert.match(initSlice, /if \(cancelled\) return;/);
|
||||
});
|
||||
|
||||
test('global hotkey registration cleans up across StrictMode remount', () => {
|
||||
const systemEffectsSource = readFileSync(
|
||||
new URL('../state/systemSettingsEffects.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const hotkeyAt = systemEffectsSource.indexOf('Persist and sync toggle window hotkey setting');
|
||||
assert.notEqual(hotkeyAt, -1);
|
||||
const hotkeyEffect = systemEffectsSource.slice(hotkeyAt, hotkeyAt + 2200);
|
||||
assert.match(hotkeyEffect, /let cancelled = false;/);
|
||||
assert.match(hotkeyEffect, /if \(cancelled\) return;/);
|
||||
assert.match(
|
||||
hotkeyEffect,
|
||||
/if \(didRegister\) \{\s*\n\s*bridge\?\.unregisterGlobalHotkey/,
|
||||
'cleanup must unregister a registration started by this effect',
|
||||
);
|
||||
// Early return before notify must not skip returning the cleanup function.
|
||||
assert.doesNotMatch(
|
||||
hotkeyEffect,
|
||||
/if \(!persistMountedRef\.current\) return;\s*\n\s*notifySettingsChanged/,
|
||||
);
|
||||
});
|
||||
|
||||
test('settings persistMountedRef resets on StrictMode cleanup', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const markAt = settingsSource.indexOf('Mark persist effects mounted AFTER all persist useEffects');
|
||||
assert.notEqual(markAt, -1);
|
||||
const markEffect = settingsSource.slice(markAt, markAt + 500);
|
||||
assert.match(markEffect, /persistMountedRef\.current = true;/);
|
||||
assert.match(
|
||||
markEffect,
|
||||
/return \(\) => \{\s*\n\s*persistMountedRef\.current = false;\s*\n\s*\};/,
|
||||
'remount must treat boot as a fresh mount, not a settings change',
|
||||
);
|
||||
});
|
||||
|
||||
test('tray panel connect flush latches against StrictMode double invoke', () => {
|
||||
const sideEffectsSource = readFileSync(
|
||||
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(sideEffectsSource, /pendingTrayConnectFlushKeyRef/);
|
||||
assert.match(
|
||||
sideEffectsSource,
|
||||
/if \(pendingTrayConnectFlushKeyRef\.current === flushKey\) return;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ssh transport idle TTL notify latches against StrictMode double invoke', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(settingsSource, /lastPushedSshTransportIdleTtlRef/);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/if \(lastPushedSshTransportIdleTtlRef\.current === sshTransportIdleTtlMs\) return;/,
|
||||
);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/lastPushedSshTransportIdleTtlRef\.current = sshTransportIdleTtlMs;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal selection Ask-AI payload is consumed once under StrictMode', () => {
|
||||
const hostSource = readFileSync(
|
||||
new URL('../../components/terminalLayer/TerminalLayerSupport.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(hostSource, /consumedTerminalSelectionRequestIds/);
|
||||
assert.doesNotMatch(
|
||||
hostSource,
|
||||
/consumedTerminalSelectionRequestIdRef/,
|
||||
'component refs reset on StrictMode remount; use a module Set',
|
||||
);
|
||||
assert.match(
|
||||
hostSource,
|
||||
/if \(consumedTerminalSelectionRequestIds\.has\(pendingTerminalSelection\.requestId\)\)/,
|
||||
);
|
||||
const latchAt = hostSource.indexOf('markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId)');
|
||||
const draftAt = hostSource.indexOf('updateDraft(scopeKey, defaultAgentId');
|
||||
assert.ok(latchAt > 0 && draftAt > latchAt, 'must latch before mutating the draft');
|
||||
});
|
||||
|
||||
test('Codex App Server interaction bridge is app-singleton like MCP approvals', () => {
|
||||
assert.match(appSource, /setupCodexAppServerInteractionBridge/);
|
||||
const panelSource = readFileSync(
|
||||
new URL('../../components/AIChatSidePanel.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panelSource,
|
||||
/setupCodexAppServerInteractionBridge/,
|
||||
'per-panel Codex IPC listeners fan out approvals under retained multi-tab mounts',
|
||||
);
|
||||
});
|
||||
104
application/app/tabShortcutTargets.test.ts
Normal file
104
application/app/tabShortcutTargets.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('hidden sftp tab is omitted from default number shortcut targets', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1'],
|
||||
editorTabIds: [],
|
||||
}),
|
||||
['vault', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('editor tabs already present in native ordering are not appended twice', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'editor:file-1', 'plugin-view:one'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['vault', 'sftp', 'session-1', 'editor:file-1', 'plugin-view:one'],
|
||||
);
|
||||
});
|
||||
|
||||
test('pinned tabs cannot be duplicated by a malformed persisted work ordering', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['vault', 'session-1', 'sftp'],
|
||||
editorTabIds: [],
|
||||
}),
|
||||
['vault', 'sftp', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcut number map uses 1-based indices matching Ctrl/Cmd+[1...9]', () => {
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.get('vault'), 1);
|
||||
assert.equal(map.get('sftp'), 2);
|
||||
assert.equal(map.get('session-1'), 3);
|
||||
assert.equal(map.get('workspace-1'), 4);
|
||||
});
|
||||
|
||||
test('shortcut number map skips pinned tabs in shell-only mode', () => {
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.has('vault'), false);
|
||||
assert.equal(map.has('sftp'), false);
|
||||
assert.equal(map.get('session-1'), 1);
|
||||
assert.equal(map.get('workspace-1'), 2);
|
||||
});
|
||||
|
||||
test('shortcut number map caps at nine entries', () => {
|
||||
const orderedTabs = Array.from({ length: 12 }, (_, index) => `session-${index + 1}`);
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs,
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.size, 9);
|
||||
assert.equal(map.get('session-1'), 1);
|
||||
assert.equal(map.get('session-9'), 9);
|
||||
assert.equal(map.has('session-10'), false);
|
||||
});
|
||||
33
application/app/tabShortcutTargets.ts
Normal file
33
application/app/tabShortcutTargets.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Tab ids targeted by keyboard tab navigation shortcuts. */
|
||||
export function buildNumberShortcutTabTargets(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): string[] {
|
||||
const workTabs = [...new Set([...params.orderedTabs, ...params.editorTabIds])];
|
||||
if (params.shellOnlyTabNumberShortcuts) {
|
||||
return workTabs;
|
||||
}
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...new Set([...pinnedTabs, ...workTabs])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tab ids to Cmd/Ctrl+[1...9] shortcut indices (1-based).
|
||||
* Only the first nine shortcut targets receive a number.
|
||||
*/
|
||||
export function buildTabShortcutNumberById(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): ReadonlyMap<string, number> {
|
||||
const targets = buildNumberShortcutTabTargets(params);
|
||||
const map = new Map<string, number>();
|
||||
const limit = Math.min(9, targets.length);
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
map.set(targets[index], index + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
52
application/app/themeRuntimeBridge.ts
Normal file
52
application/app/themeRuntimeBridge.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Theme runtime actions produced by TerminalHost (`useThemeRuntime`).
|
||||
* AppSideEffects handlers (default/follow theme changes) call through this
|
||||
* bridge instead of co-hosting the hook.
|
||||
*/
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type ThemeRuntimeBridgeActions = {
|
||||
clearThemeIntent: () => void;
|
||||
settleManualThemeIntent: () => void;
|
||||
pickTerminalTheme: (themeId: string) => void;
|
||||
resolveFocusedAppearance: (...args: never[]) => unknown;
|
||||
currentTerminalTheme: unknown;
|
||||
globalAppearance: unknown;
|
||||
};
|
||||
|
||||
class ThemeRuntimeBridge {
|
||||
private actions: ThemeRuntimeBridgeActions | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
get = (): ThemeRuntimeBridgeActions | null => this.actions;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
set(next: ThemeRuntimeBridgeActions | null): void {
|
||||
if (this.actions === next) return;
|
||||
this.actions = next;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
const bridge = new ThemeRuntimeBridge();
|
||||
|
||||
export function registerThemeRuntimeActions(
|
||||
actions: ThemeRuntimeBridgeActions | null,
|
||||
): void {
|
||||
bridge.set(actions);
|
||||
}
|
||||
|
||||
export function getThemeRuntimeActions(): ThemeRuntimeBridgeActions | null {
|
||||
return bridge.get();
|
||||
}
|
||||
|
||||
export function subscribeThemeRuntimeActions(listener: Listener): () => void {
|
||||
return bridge.subscribe(listener);
|
||||
}
|
||||
30
application/app/topTabsChromeTheme.test.ts
Normal file
30
application/app/topTabsChromeTheme.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
|
||||
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
|
||||
assert.match(chromeThemeSource, /resolveReadableForegroundForHsl\(cursor\)/);
|
||||
const restoreBlock = chromeThemeSource.match(
|
||||
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
|
||||
)?.[0] ?? "";
|
||||
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
|
||||
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
|
||||
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
|
||||
});
|
||||
|
||||
test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
const source = readFileSync(new URL("./topTabsChromeTheme.ts", import.meta.url), "utf8");
|
||||
const supportSource = readFileSync(new URL("../../components/terminalLayer/TerminalLayerSupport.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /--primary-foreground/);
|
||||
assert.match(source, /--accent-foreground/);
|
||||
assert.match(source, /resolveReadableForegroundForHsl\(accent\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
115
application/app/topTabsChromeTheme.ts
Normal file
115
application/app/topTabsChromeTheme.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { TerminalTheme } from '../../types';
|
||||
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
|
||||
|
||||
function hexToHslToken(hex: string): string {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
|
||||
const r = parseInt(normalized.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(normalized.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(normalized.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightnessToken(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturationToken(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
|
||||
if (element.style.getPropertyValue(property) === value) return;
|
||||
element.style.setProperty(property, value);
|
||||
};
|
||||
|
||||
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
|
||||
if (!element.style.getPropertyValue(property)) return;
|
||||
element.style.removeProperty(property);
|
||||
};
|
||||
|
||||
const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--top-tabs-bg',
|
||||
'--top-tabs-fg',
|
||||
'--top-tabs-muted',
|
||||
'--top-tabs-active-bg',
|
||||
'--top-tabs-accent',
|
||||
'--background',
|
||||
'--foreground',
|
||||
'--accent',
|
||||
'--accent-foreground',
|
||||
'--primary',
|
||||
'--primary-foreground',
|
||||
'--secondary',
|
||||
'--border',
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
let topTabsChromeThemeVarsApplied = false;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!topTabsChromeThemeVarsApplied) return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = false;
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
const accent = hexToHslToken(theme.colors.cursor);
|
||||
const accentForeground = resolveReadableForegroundForHsl(accent);
|
||||
const isDark = theme.type === 'dark';
|
||||
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
579
application/app/useAppStartupEffects.ts
Normal file
579
application/app/useAppStartupEffects.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePortForwardingAutoStart } from '../state/usePortForwardingAutoStart';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
|
||||
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
|
||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
||||
import type { TransferTask } from '../../domain/models';
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import {
|
||||
canApplyDedicatedResumeProgress,
|
||||
createDedicatedResumeChildUpdateBatcher,
|
||||
createDedicatedResumeProgressBatcher,
|
||||
} from './dedicatedResumeProgress';
|
||||
|
||||
type StartupEffectsContext = Record<string, any>;
|
||||
|
||||
type KeyboardInteractiveScope = "terminal" | "external";
|
||||
type KeyboardInteractiveRequestLike = {
|
||||
scope?: KeyboardInteractiveScope;
|
||||
sessionId?: string;
|
||||
hostId?: string;
|
||||
requestId?: string;
|
||||
bootEpoch?: number;
|
||||
};
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
|
||||
type KeyboardInteractiveQueueItem = { requestId: string };
|
||||
|
||||
export function shouldQueueKeyboardInteractiveRequest(
|
||||
request: KeyboardInteractiveRequestLike,
|
||||
sessions: SessionIdLike[],
|
||||
): boolean {
|
||||
if (request.scope !== "terminal") return true;
|
||||
if (!request.sessionId) return false;
|
||||
const session = sessions.find((entry) => entry.id === request.sessionId);
|
||||
if (!session) return false;
|
||||
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
|
||||
if (session.status === "disconnected") return false;
|
||||
// After disconnect → reconnect the tab is connecting again; reject MFA from
|
||||
// a superseded SSH start that still shares this sessionId.
|
||||
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
|
||||
queue: T[],
|
||||
requestId: string,
|
||||
): T[] {
|
||||
return queue.filter(request => request.requestId !== requestId);
|
||||
}
|
||||
|
||||
export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
const {dismissUpdate, enabled = true, groupConfigs, hosts, resumeHosts, identities,
|
||||
hasRuntimeTunnel, installUpdate, isVaultInitialized, keys, knownHosts, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
|
||||
t, terminalSettings, updateState, workspaces,
|
||||
} = ctx;
|
||||
// Vault hosts for tray/menu; resumeHosts may include ephemeral quick-connect rows.
|
||||
const dedicatedResumeHosts = resumeHosts ?? hosts;
|
||||
const sessionsRef = useRef(sessions);
|
||||
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
|
||||
// After app restart (or soft-resume miss), unfinished transfers reconnect via
|
||||
// a dedicated SFTP session. Prefer resumeHosts (vault + ephemeral) so
|
||||
// quick-connect transfers can re-auth without "Cannot find host in your vault".
|
||||
useEffect(() => {
|
||||
if (!enabled || !isVaultInitialized) {
|
||||
sftpTransferCenterStore.setDedicatedResumeHandler(null);
|
||||
return;
|
||||
}
|
||||
sftpTransferCenterStore.setDedicatedResumeHandler(async (task) => {
|
||||
// Keep reconnectRequired true until the first progress/completion so the
|
||||
// play control stays a spinner during auth + session setup.
|
||||
sftpTransferCenterStore.patchTask(task.id, {
|
||||
status: "pending",
|
||||
error: undefined,
|
||||
reconnectRequired: true,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
const children = sftpTransferCenterStore.getSnapshot().tasks.filter(
|
||||
(row) => row.parentTaskId === task.id,
|
||||
);
|
||||
// rAF-coalesce progress so dedicated resume does not flood the global center.
|
||||
type ProgressSample = {
|
||||
transferred: number;
|
||||
total: number;
|
||||
speed: number;
|
||||
checkpointBytes?: number;
|
||||
resumeStage?: TransferTask["resumeStage"];
|
||||
downloadCheckpointBytes?: number;
|
||||
uploadCheckpointBytes?: number;
|
||||
sourceFingerprint?: string;
|
||||
};
|
||||
// One rAF coalesce only — main process already time-throttles IPC.
|
||||
// A second 500ms timer here made dedicated-resume bars jump.
|
||||
const applyProgress = (progress: ProgressSample) => {
|
||||
const current = sftpTransferCenterStore.getSnapshot().tasks.find((row) => row.id === task.id);
|
||||
if (!current || current.status === "cancelled") return;
|
||||
if (current.status === "pausing" || current.status === "paused") {
|
||||
if (hasNewSourceFingerprint(current.sourceFingerprint, progress.sourceFingerprint)) {
|
||||
sftpTransferCenterStore.patchTask(task.id, { sourceFingerprint: progress.sourceFingerprint });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The final sample can still be queued in requestAnimationFrame after
|
||||
// the resume promise settles. Never let it turn a completed/failed row
|
||||
// back into a permanently "transferring" task.
|
||||
if (!canApplyDedicatedResumeProgress(current.status)) return;
|
||||
// Directory parents use file-count progress; single files use bytes.
|
||||
// Prefer durable contiguous checkpoint when the bridge supplies it.
|
||||
const durableCheckpoint = task.isDirectory
|
||||
? progress.transferred
|
||||
: (progress.checkpointBytes ?? progress.transferred);
|
||||
// Keep progress monotonic so a late force-checkpoint paint cannot hide
|
||||
// later bytes, and the bar never freezes at the pre-quit offset.
|
||||
const nextTransferred = Math.max(current.transferredBytes ?? 0, progress.transferred);
|
||||
const nextCheckpoint = task.isDirectory
|
||||
? Math.max(current.checkpointBytes ?? 0, progress.transferred)
|
||||
: Math.max(current.checkpointBytes ?? 0, durableCheckpoint);
|
||||
sftpTransferCenterStore.patchTask(task.id, {
|
||||
status: "transferring",
|
||||
transferredBytes: nextTransferred,
|
||||
...(progress.total > 0 ? { totalBytes: progress.total } : {}),
|
||||
speed: progress.speed,
|
||||
...(task.isDirectory
|
||||
? { checkpointBytes: nextCheckpoint, progressMode: "files" as const }
|
||||
: {
|
||||
checkpointBytes: nextCheckpoint,
|
||||
resumeStage: progress.resumeStage,
|
||||
downloadCheckpointBytes: progress.downloadCheckpointBytes,
|
||||
uploadCheckpointBytes: progress.uploadCheckpointBytes,
|
||||
sourceFingerprint: progress.sourceFingerprint,
|
||||
}),
|
||||
reconnectRequired: false,
|
||||
error: undefined,
|
||||
phase: "transferring",
|
||||
ownerId: "dedicated-resume",
|
||||
});
|
||||
};
|
||||
const progressBatcher = createDedicatedResumeProgressBatcher<ProgressSample>({
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (handle) => window.cancelAnimationFrame(handle),
|
||||
canApply: () => {
|
||||
const current = sftpTransferCenterStore.getSnapshot().tasks.find((row) => row.id === task.id);
|
||||
return !!current && canApplyDedicatedResumeProgress(current.status);
|
||||
},
|
||||
apply: applyProgress,
|
||||
});
|
||||
const childUpdateBatcher = createDedicatedResumeChildUpdateBatcher({
|
||||
// Use the restart snapshot, not repeated linear store lookups. Completed
|
||||
// rows disappear as batches compact, but later updates for those ids can
|
||||
// still stay in the same bounded batching path safely.
|
||||
getTaskCount: () => children.length + 1,
|
||||
hasTask: (() => {
|
||||
const retainedChildIds = new Set(children.map((child) => child.id));
|
||||
return (taskId: string) => retainedChildIds.has(taskId);
|
||||
})(),
|
||||
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
|
||||
});
|
||||
let acceptsResumeCallbacks = true;
|
||||
try {
|
||||
return await resumeTransferWithDedicatedSession(
|
||||
task,
|
||||
{
|
||||
hosts: dedicatedResumeHosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
},
|
||||
(progress) => {
|
||||
if (acceptsResumeCallbacks) progressBatcher.push(progress);
|
||||
},
|
||||
{
|
||||
children,
|
||||
onChildUpdate: (child) => {
|
||||
if (acceptsResumeCallbacks) {
|
||||
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" });
|
||||
}
|
||||
},
|
||||
onDirectoryCheckpointUpdate: (checkpoint) => {
|
||||
if (acceptsResumeCallbacks) {
|
||||
sftpTransferCenterStore.patchTask(task.id, {
|
||||
directoryResumeCheckpoint: checkpoint,
|
||||
});
|
||||
}
|
||||
},
|
||||
shouldAbort: () => {
|
||||
const current = sftpTransferCenterStore.getSnapshot().tasks.find((row) => row.id === task.id);
|
||||
// interrupted is the pre-reconnect persisted state — do not abort a
|
||||
// live dedicated walk just because children/parent still show it.
|
||||
return !current
|
||||
|| current.status === "cancelled"
|
||||
|| current.status === "paused";
|
||||
},
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
acceptsResumeCallbacks = false;
|
||||
progressBatcher.finish();
|
||||
childUpdateBatcher.flush();
|
||||
}
|
||||
});
|
||||
return () => sftpTransferCenterStore.setDedicatedResumeHandler(null);
|
||||
}, [dedicatedResumeHosts, enabled, identities, isVaultInitialized, keys, knownHosts, terminalSettings]);
|
||||
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
const toastedUpdateVersionRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
if (toastedUpdateVersionRef.current === version) return;
|
||||
toastedUpdateVersionRef.current = version;
|
||||
toast.info(
|
||||
t('update.available.message', { version }),
|
||||
{
|
||||
title: t('update.available.title'),
|
||||
duration: 8000, // Show longer for update notifications
|
||||
onClick: () => {
|
||||
void openSettingsWindow();
|
||||
// Dismiss the update so the toast doesn't re-fire on every render.
|
||||
// On unsupported platforms (where autoDownloadStatus stays 'idle')
|
||||
// this is the only way to suppress the notification for this version.
|
||||
// On supported platforms this toast only shows before auto-download
|
||||
// starts, and the Settings window's own useUpdateCheck will pick up
|
||||
// the download state via IPC events independently of the dismiss.
|
||||
dismissUpdate();
|
||||
},
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [enabled, updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
|
||||
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
|
||||
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
|
||||
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
if (prev === updateState.autoDownloadStatus) return;
|
||||
|
||||
if (updateState.autoDownloadStatus === 'ready') {
|
||||
const version = updateState.latestRelease?.version ?? '';
|
||||
toast.info(
|
||||
t('update.readyToInstall.message', { version }),
|
||||
{
|
||||
title: t('update.readyToInstall.title'),
|
||||
duration: 0,
|
||||
actionLabel: t('update.restartNow'),
|
||||
onClick: () => installUpdate(),
|
||||
}
|
||||
);
|
||||
} else if (updateState.autoDownloadStatus === 'error') {
|
||||
toast.error(
|
||||
t('update.downloadFailed.message'),
|
||||
{
|
||||
title: t('update.downloadFailed.title'),
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
onClick: () => void openSettingsWindow(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [enabled, updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
enabled,
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.updateTrayMenuData) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
const sessionsForTray = sessions.map((s) => {
|
||||
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
|
||||
return {
|
||||
id: s.id,
|
||||
label: s.hostname,
|
||||
hostLabel: s.hostLabel,
|
||||
status: s.status,
|
||||
workspaceId: s.workspaceId,
|
||||
workspaceTitle: ws?.title,
|
||||
aiHidden: s.hiddenFromTabs === true,
|
||||
};
|
||||
});
|
||||
|
||||
const hostsForSystemMenu = hosts
|
||||
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
|
||||
.map((host: any) => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
group: host.group,
|
||||
pinned: host.pinned,
|
||||
lastConnectedAt: host.lastConnectedAt,
|
||||
protocol: host.protocol,
|
||||
}));
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules.map((rule: any) => ({
|
||||
...rule,
|
||||
canStop: hasRuntimeTunnel(rule.id),
|
||||
})),
|
||||
hosts: hostsForSystemMenu,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(async () => {
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (!hasDirty) {
|
||||
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
|
||||
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
|
||||
));
|
||||
if (unfinishedTasks.length > 0) {
|
||||
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [enabled, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const unsubscribeEvents = bridge?.onGlobalSftpTransferEvent?.((event) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent(event);
|
||||
});
|
||||
const restartBackgroundTransfer = async (taskId: string, fromBeginning: boolean) => {
|
||||
const task = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task || !bridge?.openSftpForSession || !bridge.startStreamTransfer) return;
|
||||
const sessionId = task.direction === "upload" ? task.targetConnectionId : task.sourceConnectionId;
|
||||
if (!sessionId || sessionId === "agent" || sessionId === "local") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "failed",
|
||||
transferId: taskId,
|
||||
error: "The original server session is unavailable",
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let sftpId: string | undefined;
|
||||
try {
|
||||
const checkpointBytes = fromBeginning ? 0 : (task.checkpointBytes ?? task.transferredBytes ?? 0);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "queued", transferId: taskId });
|
||||
// Admit first so agent resume does not pin session-backed SFTP handles
|
||||
// while waiting for main-process concurrency.
|
||||
const result = await globalSftpTransferScheduler.run(
|
||||
"background-agent",
|
||||
task.id,
|
||||
getSftpTransferResourceKeys({
|
||||
sourceHostId: task.sourceHostId,
|
||||
targetHostId: task.targetHostId,
|
||||
}),
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async () => {
|
||||
sftpId = await bridge.openSftpForSession!(sessionId);
|
||||
return bridge.startStreamTransfer!({
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: task.direction === "upload" ? "local" : "sftp",
|
||||
targetType: task.direction === "download" ? "local" : "sftp",
|
||||
sourceSftpId: task.direction === "download" ? sftpId : undefined,
|
||||
targetSftpId: task.direction === "upload" ? sftpId : undefined,
|
||||
// Keep host-scoped path gates across session reopen (Codex P1).
|
||||
sourceHostId: task.sourceHostId,
|
||||
targetHostId: task.targetHostId,
|
||||
totalBytes: task.totalBytes,
|
||||
resumable: task.resumable !== false,
|
||||
checkpointBytes,
|
||||
resumeStage: fromBeginning ? undefined : task.resumeStage,
|
||||
downloadCheckpointBytes: fromBeginning ? 0 : task.downloadCheckpointBytes,
|
||||
uploadCheckpointBytes: fromBeginning ? 0 : task.uploadCheckpointBytes,
|
||||
sourceFingerprint: fromBeginning ? undefined : task.sourceFingerprint,
|
||||
skipAdmission: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
// Same-id retry stole ownership; wait for the live owner's terminal
|
||||
// status instead of treating this invoke as completed (Codex P2).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for live owner terminal status only (no fixed deadline).
|
||||
for (;;) {
|
||||
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed" || status === "cancelled" || status === "failed") {
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "cancelled",
|
||||
transferId: task.id,
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
// completed: events already applied; cancelled handled above.
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
|
||||
} else if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
} else {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: task.id, endedAt: Date.now() });
|
||||
}
|
||||
} catch (error) {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "failed",
|
||||
transferId: task.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
} finally {
|
||||
if (sftpId) await bridge.closeSftp?.(sftpId).catch(() => {});
|
||||
}
|
||||
};
|
||||
const unregisterOwner = sftpTransferCenterStore.registerOwner("background-agent", {
|
||||
pause: async (taskId) => {
|
||||
const result = await bridge?.pauseTransfer?.(taskId);
|
||||
if (result?.success) sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "paused",
|
||||
transferId: taskId,
|
||||
checkpointBytes: result.checkpointBytes,
|
||||
resumeStage: result.resumeStage,
|
||||
downloadCheckpointBytes: result.downloadCheckpointBytes,
|
||||
uploadCheckpointBytes: result.uploadCheckpointBytes,
|
||||
sourceFingerprint: result.sourceFingerprint,
|
||||
});
|
||||
},
|
||||
resume: async (taskId) => {
|
||||
const result = await bridge?.resumeTransfer?.(taskId);
|
||||
if (result?.success) {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "resumed", transferId: taskId });
|
||||
} else {
|
||||
sftpTransferCenterStore.markReconnectRequired(
|
||||
taskId,
|
||||
result?.reason ?? "The original server connection is unavailable",
|
||||
);
|
||||
setTimeout(() => { void sftpTransferCenterStore.resume(taskId); }, 0);
|
||||
}
|
||||
},
|
||||
cancel: async (taskId) => {
|
||||
await bridge?.cancelTransfer?.(taskId);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: taskId, endedAt: Date.now() });
|
||||
},
|
||||
retry: async (taskId) => { await restartBackgroundTransfer(taskId, true); },
|
||||
prioritize: async (taskId) => { await bridge?.prioritizeTransfer?.(taskId); },
|
||||
dismiss: (taskId, prunedTask) => {
|
||||
const task = prunedTask
|
||||
?? sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) return;
|
||||
void bridge?.cleanupTransferArtifacts?.({
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
stagedTargetPath: task.stagedTargetPath,
|
||||
});
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
unsubscribeEvents?.();
|
||||
unregisterOwner();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
|
||||
if (request.scope === "terminal" && request.requestId) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
sessionId: request.sessionId,
|
||||
hostId: request.hostId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
allowSavePassword: request.allowSavePassword !== false,
|
||||
}]);
|
||||
});
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
|
||||
});
|
||||
const onTerminalDisconnected = (event: Event) => {
|
||||
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
|
||||
if (!sessionId) return;
|
||||
setKeyboardInteractiveQueue((prev) => {
|
||||
const doomed = prev.filter((request) => request.sessionId === sessionId);
|
||||
for (const request of doomed) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return prev.filter((request) => request.sessionId !== sessionId);
|
||||
});
|
||||
};
|
||||
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
unsubscribeCancelled?.();
|
||||
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||
};
|
||||
}, [enabled, setKeyboardInteractiveQueue]);
|
||||
|
||||
|
||||
}
|
||||
28
application/app/useAppThemeStyle.ts
Normal file
28
application/app/useAppThemeStyle.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import { useSettingsChromeStore } from '../state/settingsChromeStore';
|
||||
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
|
||||
/**
|
||||
* App theme CSS variables for surfaces that need them (vault surface, plugin
|
||||
* theme tokens). Reads accent from appearanceChromeStore and UI theme ids from
|
||||
* settingsChromeStore so accent drags only re-render the leaf that applies the
|
||||
* vars, never the App shell.
|
||||
*/
|
||||
export function useAppThemeStyle(): React.CSSProperties {
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
|
||||
return useMemo(() => {
|
||||
const tokens = getUiThemeById(
|
||||
resolvedTheme,
|
||||
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
|
||||
).tokens;
|
||||
return {
|
||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||
colorScheme: resolvedTheme,
|
||||
} as React.CSSProperties;
|
||||
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
|
||||
}
|
||||
310
application/app/workTabSurface.test.ts
Normal file
310
application/app/workTabSurface.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildOrderedWorkTabIds,
|
||||
isHostTreeWorkTabSurface,
|
||||
isRootPageTabId,
|
||||
isTerminalContentTabSurface,
|
||||
reorderWorkTabIds,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
shouldOpenHostEditOnWorkSurface,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background,
|
||||
foreground: type === 'dark' ? '#ffffff' : '#000000',
|
||||
cursor: '#888888',
|
||||
selection: '#555555',
|
||||
black: '#000000',
|
||||
red: '#ff0000',
|
||||
green: '#00ff00',
|
||||
yellow: '#ffff00',
|
||||
blue: '#0000ff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#444444',
|
||||
brightRed: '#ff5555',
|
||||
brightGreen: '#55ff55',
|
||||
brightYellow: '#ffff55',
|
||||
brightBlue: '#5555ff',
|
||||
brightMagenta: '#ff55ff',
|
||||
brightCyan: '#55ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
|
||||
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order removes duplicate ids before rendering', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(
|
||||
['session-2', 'session-1', 'session-2', 'session-1'],
|
||||
['session-1', 'session-2', 'session-3', 'session-3'],
|
||||
),
|
||||
['session-2', 'session-1', 'session-3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order reorders with newly materialized tabs', () => {
|
||||
assert.deepEqual(
|
||||
reorderWorkTabIds(
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
'session-1',
|
||||
'session-3',
|
||||
'after',
|
||||
),
|
||||
['session-2', 'session-3', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('vault'), true);
|
||||
assert.equal(isRootPageTabId('sftp'), true);
|
||||
assert.equal(isRootPageTabId('session-1'), false);
|
||||
});
|
||||
|
||||
test('host edit overlay prefers work-surface editor except on vault/sftp/plugin tabs', () => {
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('session-1'), true);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('workspace-1'), true);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('editor:file-1'), true);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('vault'), false);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('sftp'), false);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('plugin-view:demo'), false);
|
||||
});
|
||||
|
||||
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
const logViewIds = new Set(['log-1']);
|
||||
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
|
||||
|
||||
for (const activeTabId of orderedTabs) {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared host tree recognizes active log view before tab ordering catches up', () => {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId: 'log-1',
|
||||
logViewIds: new Set(['log-1']),
|
||||
orderedTabs: [],
|
||||
sessionIds: new Set(),
|
||||
workspaceIds: new Set(),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('shared host tree stays hidden for native plugin view tabs', () => {
|
||||
const pluginTabId = 'plugin-view:com.example.view:com.example.view.panel';
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId: pluginTabId,
|
||||
orderedTabs: [pluginTabId],
|
||||
sessionIds: new Set(),
|
||||
workspaceIds: new Set(),
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('terminal content surface is limited to sessions and workspaces', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
|
||||
});
|
||||
|
||||
test('shared host tree resolves active host ids across work tab types', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', hostId: 'host-1' },
|
||||
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [{
|
||||
id: 'workspace-1',
|
||||
focusedSessionId: 'session-2',
|
||||
root: { id: 'pane-2', type: 'pane', sessionId: 'session-2' },
|
||||
}] as Workspace[];
|
||||
const editorTabs = [
|
||||
{ id: 'file-1', hostId: 'host-3' },
|
||||
] as EditorTab[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the first workspace session when focused session is missing', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
|
||||
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [{
|
||||
id: 'workspace-1',
|
||||
focusedSessionId: 'missing-session',
|
||||
root: {
|
||||
id: 'split-1',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
|
||||
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
}] as Workspace[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({
|
||||
activeTabId: 'workspace-1',
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs: [],
|
||||
}), 'host-1');
|
||||
});
|
||||
|
||||
test('shared host tree fallback prefers workspace tree order over sessions array order', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
|
||||
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [{
|
||||
id: 'workspace-1',
|
||||
focusedSessionId: 'missing-session',
|
||||
root: {
|
||||
id: 'split-1',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
|
||||
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
}] as Workspace[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({
|
||||
activeTabId: 'workspace-1',
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs: [],
|
||||
}), 'host-1');
|
||||
});
|
||||
|
||||
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('follow-app host tree applies custom accent onto the published base theme', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'custom',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '0 100% 50%',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
assert.notEqual(resolved.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: null,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map(),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
190
application/app/workTabSurface.ts
Normal file
190
application/app/workTabSurface.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { isPluginViewTabId } from '../state/pluginViewTabStore';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
function uniqueTabIds(tabIds: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const uniqueIds: string[] = [];
|
||||
for (const tabId of tabIds) {
|
||||
if (!tabId || seen.has(tabId)) continue;
|
||||
seen.add(tabId);
|
||||
uniqueIds.push(tabId);
|
||||
}
|
||||
return uniqueIds;
|
||||
}
|
||||
|
||||
export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Host edit from overlays (Quick Switcher): use the terminal work-surface
|
||||
* HostDetailsPanel when a work tab is active; otherwise deep-link into Vault.
|
||||
*/
|
||||
export function shouldOpenHostEditOnWorkSurface(activeTabId: string): boolean {
|
||||
return !isRootPageTabId(activeTabId) && !isPluginViewTabId(activeTabId);
|
||||
}
|
||||
|
||||
export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
): string[] {
|
||||
const uniqueAllTabIds = uniqueTabIds(allTabIds);
|
||||
const allTabIdSet = new Set(uniqueAllTabIds);
|
||||
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}
|
||||
|
||||
export function reorderWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
): string[] {
|
||||
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
|
||||
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
|
||||
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
let nextTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
nextTargetIndex -= 1;
|
||||
}
|
||||
if (position === 'after') {
|
||||
nextTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(nextTargetIndex, 0, draggedId);
|
||||
return currentOrder;
|
||||
}
|
||||
|
||||
export function isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds = new Set(),
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
activeTabId: string;
|
||||
logViewIds?: ReadonlySet<string>;
|
||||
orderedTabs: readonly string[];
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!enabled) return false;
|
||||
if (isRootPageTabId(activeTabId)) return false;
|
||||
if (isPluginViewTabId(activeTabId)) return false;
|
||||
return orderedTabs.includes(activeTabId)
|
||||
|| isEditorTabId(activeTabId)
|
||||
|| logViewIds.has(activeTabId)
|
||||
|| sessionIds.has(activeTabId)
|
||||
|| workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceTargetSession(
|
||||
workspace: Workspace,
|
||||
sessions: readonly TerminalSession[],
|
||||
): TerminalSession | undefined {
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
return resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceTargetSessionFromMap(
|
||||
workspace: Workspace,
|
||||
sessionById: ReadonlyMap<string, TerminalSession>,
|
||||
): TerminalSession | undefined {
|
||||
const orderedSessionIds = collectSessionIds(workspace.root);
|
||||
const workspaceSessionIdSet = new Set(orderedSessionIds);
|
||||
const focusedSession = workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: undefined;
|
||||
const validFocusedSession = focusedSession && workspaceSessionIdSet.has(focusedSession.id)
|
||||
? focusedSession
|
||||
: undefined;
|
||||
if (validFocusedSession) return validFocusedSession;
|
||||
for (const sessionId of orderedSessionIds) {
|
||||
const session = sessionById.get(sessionId);
|
||||
if (session) return session;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
}): string | null {
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorId = fromEditorTabId(activeTabId);
|
||||
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeTabId);
|
||||
if (activeSession) return activeSession.hostId ?? null;
|
||||
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
const targetSession = resolveWorkspaceTargetSession(activeWorkspace, sessions);
|
||||
return targetSession?.hostId ?? null;
|
||||
}
|
||||
|
||||
export function resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}: {
|
||||
activeHostId: string | null;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
354
application/convergentSyncMigration.test.ts
Normal file
354
application/convergentSyncMigration.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const localStorageValues = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => localStorageValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => localStorageValues.set(key, value),
|
||||
removeItem: (key: string) => localStorageValues.delete(key),
|
||||
clear: () => localStorageValues.clear(),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
planConvergentSyncMigration,
|
||||
stripConvergentSyncEnvelope,
|
||||
} = await import('../domain/convergentSync/index.ts');
|
||||
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
|
||||
const {
|
||||
initializePreparedConvergentMigration,
|
||||
prepareConvergentSyncMigration,
|
||||
} = await import('./convergentSyncMigration.ts');
|
||||
|
||||
function payload(): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorageValues.clear();
|
||||
});
|
||||
|
||||
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
|
||||
const remotePayload: SyncPayload = {
|
||||
...payload(),
|
||||
hosts: [{
|
||||
id: 'host-1',
|
||||
label: 'Legacy host',
|
||||
hostname: 'legacy.example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
}],
|
||||
};
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
getAllProviders: () => ({
|
||||
github: { provider: 'github', status: 'connected' },
|
||||
}),
|
||||
loadConvergentProviderBaseline: async () => null,
|
||||
loadSyncBase: async () => null,
|
||||
downloadFromProvider: async () => ({
|
||||
provider: 'github',
|
||||
payload: remotePayload,
|
||||
remoteFile: {
|
||||
meta: {
|
||||
version: 7,
|
||||
updatedAt: NOW - 1,
|
||||
deviceId: 'legacy-device',
|
||||
deviceName: 'Legacy device',
|
||||
appVersion: '1.0.0',
|
||||
iv: '',
|
||||
salt: '',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
kdfIterations: 1,
|
||||
},
|
||||
payload: 'ciphertext',
|
||||
},
|
||||
}),
|
||||
getState: () => ({ deviceId: 'local-device' }),
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
const prepared = await prepareConvergentSyncMigration(payload(), manager, NOW);
|
||||
|
||||
assert.equal(prepared.plan.preview.canInitialize, true);
|
||||
assert.equal(prepared.providerBaselines.length, 1);
|
||||
const baseline = prepared.providerBaselines[0]!;
|
||||
assert.equal(baseline.provider, 'github');
|
||||
assert.equal(baseline.remoteVersion, 7);
|
||||
assert.equal(baseline.remoteDeviceId, 'legacy-device');
|
||||
assert.deepEqual(baseline.materializedPayload, remotePayload);
|
||||
assert.deepEqual(baseline.state, prepared.plan.state);
|
||||
});
|
||||
|
||||
test('initialization applies the protected preview before persisting and enabling the replica', async () => {
|
||||
const localPayload = payload();
|
||||
const liveLocalPayload = { ...payload(), knownHosts: [] };
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
assert.equal(plan.preview.canInitialize, true);
|
||||
const calls: string[] = [];
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
withConvergentSyncLock: async (task: () => Promise<void>) => {
|
||||
calls.push('lock');
|
||||
return task();
|
||||
},
|
||||
saveConvergentReplica: async () => {
|
||||
calls.push('replica');
|
||||
},
|
||||
saveConvergentProviderBaseline: async () => {
|
||||
calls.push('baseline');
|
||||
},
|
||||
syncConvergentProvidersUnderLock: async (incoming: SyncPayload) => {
|
||||
calls.push('publish');
|
||||
assert.equal(incoming.convergentSync?.schemaVersion, 2);
|
||||
return new Map();
|
||||
},
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager,
|
||||
now: NOW,
|
||||
buildCurrentPayload: () => localPayload,
|
||||
buildPreApplyPayload: () => {
|
||||
calls.push('snapshot');
|
||||
return liveLocalPayload;
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: async (incoming) => {
|
||||
calls.push('apply');
|
||||
assert.equal(incoming.convergentSync?.schemaVersion, 2);
|
||||
},
|
||||
runProtectedApply: async (options) => {
|
||||
calls.push('protect');
|
||||
if (!options.prepareApply) throw new Error('Expected prepared migration apply');
|
||||
const apply = await options.prepareApply();
|
||||
assert.equal(options.buildPreApplyPayload(), liveLocalPayload);
|
||||
await apply();
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['lock', 'protect', 'snapshot', 'apply', 'replica', 'publish']);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: true, initialized: true });
|
||||
});
|
||||
|
||||
test('initialization applies a concurrent provider merge before releasing the migration lock', async () => {
|
||||
const localPayload = payload();
|
||||
const mergedPayload: SyncPayload = {
|
||||
...payload(),
|
||||
hosts: [{
|
||||
id: 'remote-host',
|
||||
label: 'Remote host',
|
||||
hostname: 'remote.example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
}],
|
||||
};
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
const applied: SyncPayload[] = [];
|
||||
let currentPayload = localPayload;
|
||||
let lockHeld = false;
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
withConvergentSyncLock: async (task: () => Promise<void>) => {
|
||||
lockHeld = true;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
lockHeld = false;
|
||||
}
|
||||
},
|
||||
saveConvergentReplica: async () => {},
|
||||
saveConvergentProviderBaseline: async () => {},
|
||||
syncConvergentProvidersUnderLock: async (_incoming, applyPayload) => {
|
||||
assert.equal(lockHeld, true);
|
||||
await applyPayload(mergedPayload, async () => {});
|
||||
return new Map([[
|
||||
'github',
|
||||
{
|
||||
success: true,
|
||||
provider: 'github',
|
||||
action: 'merge',
|
||||
mergedPayload,
|
||||
mergedPayloadApplied: true,
|
||||
},
|
||||
]]);
|
||||
},
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager,
|
||||
now: NOW,
|
||||
buildCurrentPayload: () => currentPayload,
|
||||
buildPreApplyPayload: () => currentPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: async (incoming) => {
|
||||
assert.equal(lockHeld, true);
|
||||
applied.push(incoming);
|
||||
currentPayload = stripConvergentSyncEnvelope(incoming);
|
||||
},
|
||||
runProtectedApply: async (options) => {
|
||||
if (!options.prepareApply) throw new Error('Expected prepared migration apply');
|
||||
const apply = await options.prepareApply();
|
||||
await apply();
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(applied.length, 2);
|
||||
assert.equal(applied[0]?.convergentSync?.schemaVersion, 2);
|
||||
assert.equal(applied[1]?.hosts[0]?.label, 'Remote host');
|
||||
assert.equal(lockHeld, false);
|
||||
});
|
||||
|
||||
test('initialization rejects a stale preview before backup or apply', async () => {
|
||||
const localPayload = payload();
|
||||
const changedPayload: SyncPayload = {
|
||||
...payload(),
|
||||
hosts: [{
|
||||
id: 'host-after-preview',
|
||||
label: 'Added after preview',
|
||||
hostname: 'new.example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
}],
|
||||
};
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
let protectedApplyEntered = false;
|
||||
let applied = false;
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
withConvergentSyncLock: async (task: () => Promise<void>) => task(),
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await assert.rejects(
|
||||
() => initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager,
|
||||
buildCurrentPayload: () => changedPayload,
|
||||
buildPreApplyPayload: () => changedPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {
|
||||
applied = true;
|
||||
},
|
||||
runProtectedApply: async (options) => {
|
||||
protectedApplyEntered = true;
|
||||
if (!options.prepareApply) throw new Error('Expected prepared migration apply');
|
||||
await options.prepareApply();
|
||||
},
|
||||
}),
|
||||
/changed after the migration preview/i,
|
||||
);
|
||||
|
||||
assert.equal(protectedApplyEntered, true);
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
|
||||
});
|
||||
|
||||
test('blocked previews cannot enter the protected initialization transaction', async () => {
|
||||
const localPayload = payload();
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [{ provider: 'github', status: 'unavailable', message: 'offline' }],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
let entered = false;
|
||||
|
||||
await assert.rejects(
|
||||
() => initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager: {} as CloudSyncManager,
|
||||
buildCurrentPayload: () => localPayload,
|
||||
buildPreApplyPayload: () => localPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {},
|
||||
runProtectedApply: async () => {
|
||||
entered = true;
|
||||
},
|
||||
}),
|
||||
/migration is blocked/,
|
||||
);
|
||||
assert.equal(entered, false);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
|
||||
});
|
||||
|
||||
test('a locked manager cannot enter the protected initialization transaction', async () => {
|
||||
const localPayload = payload();
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload,
|
||||
localTrustedBaseline: null,
|
||||
providers: [],
|
||||
deviceId: 'device-a',
|
||||
now: NOW,
|
||||
});
|
||||
let entered = false;
|
||||
let applied = false;
|
||||
let snapshotBuilt = false;
|
||||
const manager = {
|
||||
isUnlocked: () => false,
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await assert.rejects(
|
||||
() => initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload },
|
||||
manager,
|
||||
buildCurrentPayload: () => localPayload,
|
||||
buildPreApplyPayload: () => {
|
||||
snapshotBuilt = true;
|
||||
return localPayload;
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {
|
||||
applied = true;
|
||||
},
|
||||
runProtectedApply: async () => {
|
||||
entered = true;
|
||||
},
|
||||
}),
|
||||
/Unlock cloud sync before initializing convergent migration/,
|
||||
);
|
||||
|
||||
assert.equal(entered, false);
|
||||
assert.equal(snapshotBuilt, false);
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
|
||||
});
|
||||
223
application/convergentSyncMigration.ts
Normal file
223
application/convergentSyncMigration.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
CloudProvider,
|
||||
ConvergentMigrationPreview,
|
||||
ConvergentProviderBaselineV2,
|
||||
SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import {
|
||||
cloudSyncPayloadsEqual,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
planConvergentSyncMigration,
|
||||
stripConvergentSyncEnvelope,
|
||||
validateConvergentSyncPayload,
|
||||
type ConvergentMigrationPlan,
|
||||
type ConvergentMigrationProviderInput,
|
||||
} from '../domain/convergentSync';
|
||||
import { isProviderReadyForSync } from '../domain/sync';
|
||||
import { getCloudSyncManager, type CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { markConvergentSyncInitialized } from '../infrastructure/services/convergentSyncConfig';
|
||||
import { applyProtectedSyncPayload } from './localVaultBackups';
|
||||
|
||||
export interface PreparedConvergentMigration {
|
||||
plan: ConvergentMigrationPlan;
|
||||
providerBaselines: ConvergentProviderBaselineV2[];
|
||||
localSnapshot: SyncPayload;
|
||||
}
|
||||
|
||||
interface LegacyProviderBaselineSeed {
|
||||
provider: CloudProvider;
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
remoteDeviceId: string;
|
||||
materializedPayload: SyncPayload;
|
||||
}
|
||||
|
||||
function selectLocalTrustedBaseline(baselines: SyncPayload[]): SyncPayload | null {
|
||||
if (baselines.length === 0) return null;
|
||||
const first = baselines[0];
|
||||
return baselines.every((baseline) => cloudSyncPayloadsEqual(first, baseline))
|
||||
? first
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function prepareConvergentSyncMigration(
|
||||
localPayload: SyncPayload,
|
||||
manager: CloudSyncManager = getCloudSyncManager(),
|
||||
now = Date.now(),
|
||||
): Promise<PreparedConvergentMigration> {
|
||||
if (!manager.isUnlocked()) throw new Error('Unlock cloud sync before preparing migration');
|
||||
const providers = (Object.entries(manager.getAllProviders()) as Array<[
|
||||
CloudProvider,
|
||||
ReturnType<CloudSyncManager['getProviderConnection']>,
|
||||
]>)
|
||||
.filter(([, connection]) => isProviderReadyForSync(connection))
|
||||
.map(([provider]) => provider)
|
||||
.sort();
|
||||
const baselineByProvider = new Map<CloudProvider, SyncPayload | null>();
|
||||
const providerBaselines: ConvergentProviderBaselineV2[] = [];
|
||||
const legacyBaselineSeeds: LegacyProviderBaselineSeed[] = [];
|
||||
const inputs: ConvergentMigrationProviderInput[] = await Promise.all(
|
||||
providers.map(async (provider): Promise<ConvergentMigrationProviderInput> => {
|
||||
try {
|
||||
const convergentBaseline = await manager.loadConvergentProviderBaseline(provider);
|
||||
const baseline = convergentBaseline?.materializedPayload
|
||||
?? await manager.loadSyncBase(provider);
|
||||
baselineByProvider.set(provider, baseline);
|
||||
const remote = await manager.downloadFromProvider(provider);
|
||||
if (!remote) return { provider, status: 'empty' };
|
||||
const remoteState = validateConvergentSyncPayload(remote.remoteFile.meta, remote.payload);
|
||||
if (remoteState) {
|
||||
providerBaselines.push({
|
||||
schemaVersion: 2,
|
||||
provider,
|
||||
remoteVersion: remote.remoteFile.meta.version,
|
||||
remoteUpdatedAt: remote.remoteFile.meta.updatedAt,
|
||||
remoteDeviceId: remote.remoteFile.meta.deviceId,
|
||||
materializedPayload: stripConvergentSyncEnvelope(remote.payload),
|
||||
state: remoteState,
|
||||
});
|
||||
} else {
|
||||
legacyBaselineSeeds.push({
|
||||
provider,
|
||||
remoteVersion: remote.remoteFile.meta.version,
|
||||
remoteUpdatedAt: remote.remoteFile.meta.updatedAt,
|
||||
remoteDeviceId: remote.remoteFile.meta.deviceId,
|
||||
materializedPayload: stripConvergentSyncEnvelope(remote.payload),
|
||||
});
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
status: 'ready',
|
||||
meta: remote.remoteFile.meta,
|
||||
payload: remote.payload,
|
||||
trustedBaseline: baseline,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
provider,
|
||||
status: 'unavailable',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
const localTrustedBaseline = selectLocalTrustedBaseline(
|
||||
[...baselineByProvider.values()].filter((value): value is SyncPayload => value !== null),
|
||||
);
|
||||
const localSnapshot = JSON.parse(JSON.stringify(
|
||||
stripConvergentSyncEnvelope(localPayload),
|
||||
)) as SyncPayload;
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload: localSnapshot,
|
||||
localTrustedBaseline,
|
||||
providers: inputs,
|
||||
deviceId: manager.getState().deviceId,
|
||||
now,
|
||||
});
|
||||
if (plan.state) {
|
||||
for (const seed of legacyBaselineSeeds) {
|
||||
providerBaselines.push({
|
||||
schemaVersion: 2,
|
||||
...seed,
|
||||
// The canonical migration state already incorporates this exact v1
|
||||
// remote. Keeping its original materialized snapshot lets a later
|
||||
// legacy write become a field diff without blocking the first v2 upload.
|
||||
state: plan.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
providerBaselines: providerBaselines.sort((left, right) => left.provider.localeCompare(right.provider)),
|
||||
localSnapshot,
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
export async function initializePreparedConvergentMigration(options: {
|
||||
prepared: PreparedConvergentMigration;
|
||||
buildCurrentPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
applyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
manager?: CloudSyncManager;
|
||||
now?: number;
|
||||
runProtectedApply?: typeof applyProtectedSyncPayload;
|
||||
}): Promise<ConvergentMigrationPreview> {
|
||||
const { prepared } = options;
|
||||
const manager = options.manager ?? getCloudSyncManager();
|
||||
const now = options.now ?? Date.now();
|
||||
if (!prepared.plan.preview.canInitialize || !prepared.plan.state || !prepared.plan.payload) {
|
||||
throw new Error(`Convergent migration is blocked: ${prepared.plan.preview.blockedReasons.join('; ')}`);
|
||||
}
|
||||
if (!manager.isUnlocked()) {
|
||||
throw new Error('Unlock cloud sync before initializing convergent migration');
|
||||
}
|
||||
const runProtectedApply = options.runProtectedApply ?? applyProtectedSyncPayload;
|
||||
await manager.withConvergentSyncLock(async () => {
|
||||
await runProtectedApply({
|
||||
buildPreApplyPayload: options.buildPreApplyPayload,
|
||||
translateProtectiveBackupFailure: options.translateProtectiveBackupFailure,
|
||||
prepareApply: async () => {
|
||||
const currentPayload = stripConvergentSyncEnvelope(await options.buildCurrentPayload());
|
||||
if (!cloudSyncPayloadsEqual(prepared.localSnapshot, currentPayload)) {
|
||||
throw new Error(
|
||||
'Local sync data changed after the migration preview. Review the updated migration before enabling convergent sync.',
|
||||
);
|
||||
}
|
||||
return async () => {
|
||||
await options.applyPayload(prepared.plan.payload as SyncPayload);
|
||||
for (const baseline of prepared.providerBaselines) {
|
||||
await manager.saveConvergentProviderBaseline(baseline);
|
||||
}
|
||||
await manager.saveConvergentReplica({
|
||||
schemaVersion: 2,
|
||||
state: prepared.plan.state as NonNullable<ConvergentMigrationPlan['state']>,
|
||||
updatedAt: now,
|
||||
});
|
||||
markConvergentSyncInitialized();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// The materialized v1 snapshot often stays byte-for-byte unchanged, so
|
||||
// hash-driven auto-sync cannot be trusted to publish the new envelope.
|
||||
// Force the first v2 read/merge/write/verify cycle before releasing the
|
||||
// same Web Lock used by initialization.
|
||||
const publishResults = await manager.syncConvergentProvidersUnderLock(
|
||||
prepared.plan.payload as SyncPayload,
|
||||
async (mergedPayload, commitReplica) => runProtectedApply({
|
||||
buildPreApplyPayload: options.buildPreApplyPayload,
|
||||
translateProtectiveBackupFailure: options.translateProtectiveBackupFailure,
|
||||
prepareApply: async () => {
|
||||
const currentPayload = stripConvergentSyncEnvelope(await options.buildCurrentPayload());
|
||||
if (!cloudSyncPayloadsEqual(prepared.plan.payload as SyncPayload, currentPayload)) {
|
||||
throw new Error(
|
||||
'Local sync data changed while publishing the convergent migration. Retry sync to preserve the newer local edits.',
|
||||
);
|
||||
}
|
||||
return async () => {
|
||||
await options.applyPayload(mergedPayload);
|
||||
await commitReplica();
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const failed = [...publishResults.values()].find((result) => !result.success);
|
||||
if (failed) {
|
||||
throw new Error(
|
||||
`Convergent migration could not publish to ${failed.provider}: ${failed.error ?? 'sync failed'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return prepared.plan.preview;
|
||||
}
|
||||
|
||||
export async function prepareConvergentSyncDowngrade(
|
||||
manager: CloudSyncManager = getCloudSyncManager(),
|
||||
now = Date.now(),
|
||||
): Promise<SyncPayload> {
|
||||
const replica = await manager.loadConvergentReplica();
|
||||
if (!replica) throw new Error('No convergent sync replica is available to downgrade');
|
||||
return materializeSyncPayloadFromConvergentState(replica.state, { syncedAt: now });
|
||||
}
|
||||
73
application/convergentSyncReplica.test.ts
Normal file
73
application/convergentSyncReplica.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createConvergentSyncStateFromPayload,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
} from '../domain/convergentSync/index.ts';
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
|
||||
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica.ts';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function payload(label: string): SyncPayload {
|
||||
return {
|
||||
hosts: [{
|
||||
id: 'host-1',
|
||||
label,
|
||||
hostname: 'example.com',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
}],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
test('local restore is recorded as writes on the active replica instead of replacing it', async () => {
|
||||
const state = createConvergentSyncStateFromPayload(payload('Before'), 'seed', NOW);
|
||||
let savedState = state;
|
||||
let saveCount = 0;
|
||||
const manager = {
|
||||
loadConvergentReplica: async () => ({ schemaVersion: 2 as const, state, updatedAt: NOW }),
|
||||
getState: () => ({ deviceId: 'local-device' }),
|
||||
saveConvergentReplica: async (record: { state: typeof state }) => {
|
||||
saveCount += 1;
|
||||
savedState = record.state;
|
||||
},
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
const commit = await prepareRestoredPayloadConvergentWrites(
|
||||
payload('Restored'),
|
||||
NOW + 1,
|
||||
{ manager, initialized: true },
|
||||
);
|
||||
assert.equal(saveCount, 0);
|
||||
|
||||
await commit();
|
||||
|
||||
const materialized = materializeSyncPayloadFromConvergentState(savedState, { syncedAt: NOW + 1 });
|
||||
assert.equal(saveCount, 1);
|
||||
assert.equal(materialized.hosts[0].label, 'Restored');
|
||||
assert.equal(savedState.vector['local-device'] > 0, true);
|
||||
assert.equal(savedState.vector.seed > 0, true);
|
||||
});
|
||||
|
||||
test('an initialized configuration fails closed when its active replica is missing', async () => {
|
||||
const manager = {
|
||||
loadConvergentReplica: async () => null,
|
||||
} as unknown as CloudSyncManager;
|
||||
|
||||
await assert.rejects(
|
||||
() => prepareRestoredPayloadConvergentWrites(
|
||||
payload('Restored'),
|
||||
NOW,
|
||||
{ manager, initialized: true },
|
||||
),
|
||||
/local replica is missing/,
|
||||
);
|
||||
});
|
||||
46
application/convergentSyncReplica.ts
Normal file
46
application/convergentSyncReplica.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
applyLegacySyncPayload,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
stripConvergentSyncEnvelope,
|
||||
} from '../domain/convergentSync';
|
||||
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { getConvergentSyncLocalConfig } from '../infrastructure/services/convergentSyncConfig';
|
||||
|
||||
export type CommitRestoredPayloadConvergentWrites = () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Local backups intentionally contain no active CRDT replica. Before applying
|
||||
* a restore, validate the active replica and prepare ordinary local writes.
|
||||
* The returned commit persists them only after the local import succeeds, so
|
||||
* the replica never claims a restore that the local import rejected.
|
||||
*/
|
||||
export async function prepareRestoredPayloadConvergentWrites(
|
||||
restoredPayload: SyncPayload,
|
||||
now = Date.now(),
|
||||
dependencies: {
|
||||
manager?: CloudSyncManager;
|
||||
initialized?: boolean;
|
||||
} = {},
|
||||
): Promise<CommitRestoredPayloadConvergentWrites> {
|
||||
const initialized = dependencies.initialized
|
||||
?? getConvergentSyncLocalConfig().initialized;
|
||||
if (!initialized) return async () => {};
|
||||
const manager = dependencies.manager ?? getCloudSyncManager();
|
||||
const replica = await manager.loadConvergentReplica();
|
||||
if (!replica) {
|
||||
throw new Error('Convergent sync is initialized but its local replica is missing');
|
||||
}
|
||||
const baseline = materializeSyncPayloadFromConvergentState(replica.state, {
|
||||
syncedAt: replica.updatedAt,
|
||||
});
|
||||
const state = applyLegacySyncPayload(
|
||||
replica.state,
|
||||
baseline,
|
||||
stripConvergentSyncEnvelope(restoredPayload),
|
||||
manager.getState().deviceId,
|
||||
now,
|
||||
);
|
||||
return () => manager.saveConvergentReplica({ schemaVersion: 2, state, updatedAt: now });
|
||||
}
|
||||
528
application/defaultKeyPassphrases.ts
Normal file
528
application/defaultKeyPassphrases.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import type { SSHKey } from "../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder } from "../domain/credentials";
|
||||
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../infrastructure/persistence/localStorageAdapter";
|
||||
import { encryptField, decryptField } from "../infrastructure/persistence/secureFieldAdapter";
|
||||
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
|
||||
|
||||
function defaultKeyPassphrasePathKey(keyPath: string): string {
|
||||
const isWindowsPath = /^[A-Za-z]:[\\/]/u.test(keyPath) || /^[\\/]{2}/u.test(keyPath);
|
||||
if (!isWindowsPath) return keyPath;
|
||||
const normalized = keyPath.replace(/\\/g, "/");
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
function matchingPathKeys(keyPaths: string[]): Set<string> {
|
||||
return new Set(keyPaths.map(defaultKeyPassphrasePathKey));
|
||||
}
|
||||
|
||||
export async function resolveDefaultKeyPassphraseAliases(keyPath: string): Promise<string[]> {
|
||||
const aliases = new Set([keyPath]);
|
||||
const isWindowsPath = /^[A-Za-z]:[\\/]/u.test(keyPath) || /^\\\\/u.test(keyPath);
|
||||
const normalizedKeyPath = isWindowsPath ? keyPath.replace(/\\/g, "/") : keyPath;
|
||||
aliases.add(normalizedKeyPath);
|
||||
try {
|
||||
const homeDir = await netcattyBridge.get()?.getHomeDir?.();
|
||||
if (!homeDir) return [...aliases];
|
||||
|
||||
const normalizedHome = homeDir.replace(/\\/g, "/").replace(/\/$/u, "");
|
||||
const comparableHome = defaultKeyPassphrasePathKey(normalizedHome);
|
||||
const comparableKeyPath = defaultKeyPassphrasePathKey(normalizedKeyPath);
|
||||
if (comparableKeyPath.startsWith(`${comparableHome}/`)) {
|
||||
aliases.add(`~/${normalizedKeyPath.slice(normalizedHome.length + 1)}`);
|
||||
} else if (normalizedKeyPath.startsWith("~/")) {
|
||||
const suffix = normalizedKeyPath.slice(2);
|
||||
aliases.add(`${normalizedHome}/${suffix}`);
|
||||
const nativeHome = homeDir.replace(/[\\/]+$/u, "");
|
||||
const nativeSeparator = homeDir.includes("\\") ? "\\" : "/";
|
||||
aliases.add(`${nativeHome}${nativeSeparator}${suffix.replace(/\//g, nativeSeparator)}`);
|
||||
}
|
||||
} catch {
|
||||
// The renderer bridge may be unavailable in tests or web fallback mode.
|
||||
}
|
||||
return [...aliases];
|
||||
}
|
||||
|
||||
let passphraseMutationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function runPassphraseMutation<T>(mutation: () => Promise<T>): Promise<T> {
|
||||
const result = passphraseMutationQueue.then(mutation, mutation);
|
||||
passphraseMutationQueue = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeDefaultKeyPassphraseUnlocked(
|
||||
keyPath: string,
|
||||
encrypted: string,
|
||||
aliases: string[],
|
||||
): void {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
for (const storedPath of Object.keys(store)) {
|
||||
if (storedPath !== keyPath && aliasKeys.has(defaultKeyPassphrasePathKey(storedPath))) {
|
||||
delete store[storedPath];
|
||||
}
|
||||
}
|
||||
store[keyPath] = encrypted;
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
|
||||
}
|
||||
|
||||
async function saveDefaultKeyPassphraseUnlocked(keyPath: string, passphrase: string): Promise<void> {
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const encrypted = await encryptField(passphrase) ?? passphrase;
|
||||
writeDefaultKeyPassphraseUnlocked(keyPath, encrypted, aliases);
|
||||
}
|
||||
|
||||
export async function saveDefaultKeyPassphrase(keyPath: string, passphrase: string): Promise<void> {
|
||||
return runPassphraseMutation(() => saveDefaultKeyPassphraseUnlocked(keyPath, passphrase));
|
||||
}
|
||||
|
||||
function matchingStoreEntriesChanged(
|
||||
previous: Record<string, string>,
|
||||
latest: Record<string, string> | null,
|
||||
aliasKeys: Set<string>,
|
||||
): boolean {
|
||||
const paths = new Set([
|
||||
...Object.keys(previous),
|
||||
...Object.keys(latest ?? {}),
|
||||
]);
|
||||
for (const path of paths) {
|
||||
if (
|
||||
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
||||
&& previous[path] !== latest?.[path]
|
||||
) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadDefaultKeyPassphraseOnce(keyPath: string): Promise<{
|
||||
retry: boolean;
|
||||
value: string | null;
|
||||
}> {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (!store) return { retry: false, value: null };
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
const storedPaths = Object.keys(store).filter((path) => (
|
||||
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
||||
));
|
||||
const exactIndex = storedPaths.indexOf(keyPath);
|
||||
if (exactIndex > 0) {
|
||||
storedPaths.unshift(storedPaths.splice(exactIndex, 1)[0]);
|
||||
}
|
||||
|
||||
const invalidEntries = new Map<string, string>();
|
||||
for (const storedPath of storedPaths) {
|
||||
const decrypted = await decryptField(store[storedPath]);
|
||||
if (decrypted && !isEncryptedCredentialPlaceholder(decrypted)) {
|
||||
const latestStore = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (matchingStoreEntriesChanged(store, latestStore, aliasKeys)) {
|
||||
return { retry: true, value: null };
|
||||
}
|
||||
if (latestStore) {
|
||||
let changed = false;
|
||||
for (const duplicatePath of storedPaths) {
|
||||
if (duplicatePath !== storedPath && duplicatePath in latestStore) {
|
||||
delete latestStore[duplicatePath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, latestStore);
|
||||
}
|
||||
}
|
||||
return { retry: false, value: decrypted };
|
||||
}
|
||||
invalidEntries.set(storedPath, store[storedPath]);
|
||||
}
|
||||
if (invalidEntries.size > 0) {
|
||||
const latestStore = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (matchingStoreEntriesChanged(store, latestStore, aliasKeys)) {
|
||||
return { retry: true, value: null };
|
||||
}
|
||||
if (latestStore) {
|
||||
let changed = false;
|
||||
for (const [storedPath, invalidValue] of invalidEntries) {
|
||||
if (latestStore[storedPath] === invalidValue) {
|
||||
delete latestStore[storedPath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, latestStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { retry: false, value: null };
|
||||
}
|
||||
|
||||
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const result = await loadDefaultKeyPassphraseOnce(keyPath);
|
||||
if (!result.retry) return result.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type DefaultKeyPassphraseExportRead =
|
||||
| { status: "missing" }
|
||||
| { status: "readable"; value: string }
|
||||
| { status: "unreadable" };
|
||||
|
||||
export interface DefaultKeyPassphraseVerificationRead {
|
||||
values: string[];
|
||||
unreadable: boolean;
|
||||
present: boolean;
|
||||
}
|
||||
|
||||
async function readDefaultKeyPassphrasesForVerificationOnce(
|
||||
keyPath: string,
|
||||
): Promise<{ retry: boolean; result: DefaultKeyPassphraseVerificationRead }> {
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (!store) {
|
||||
return { retry: false, result: { values: [], unreadable: false, present: false } };
|
||||
}
|
||||
|
||||
const storedPaths = Object.keys(store).filter((path) => (
|
||||
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
||||
));
|
||||
const exactIndex = storedPaths.indexOf(keyPath);
|
||||
if (exactIndex > 0) {
|
||||
storedPaths.unshift(storedPaths.splice(exactIndex, 1)[0]);
|
||||
}
|
||||
if (storedPaths.length === 0) {
|
||||
return { retry: false, result: { values: [], unreadable: false, present: false } };
|
||||
}
|
||||
|
||||
const values = new Set<string>();
|
||||
let unreadable = false;
|
||||
for (const storedPath of storedPaths) {
|
||||
try {
|
||||
const decrypted = await decryptField(store[storedPath]);
|
||||
if (decrypted && !isEncryptedCredentialPlaceholder(decrypted)) {
|
||||
values.add(decrypted);
|
||||
} else {
|
||||
unreadable = true;
|
||||
}
|
||||
} catch {
|
||||
// Export must not mutate saved credentials when secure storage is unavailable.
|
||||
unreadable = true;
|
||||
}
|
||||
}
|
||||
const latestStore = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (matchingStoreEntriesChanged(store, latestStore, aliasKeys)) {
|
||||
return {
|
||||
retry: true,
|
||||
result: { values: [], unreadable: true, present: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
retry: false,
|
||||
result: { values: [...values], unreadable, present: true },
|
||||
};
|
||||
}
|
||||
|
||||
export async function readDefaultKeyPassphrasesForVerification(
|
||||
keyPath: string,
|
||||
): Promise<DefaultKeyPassphraseVerificationRead> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const read = await readDefaultKeyPassphrasesForVerificationOnce(keyPath);
|
||||
if (!read.retry) return read.result;
|
||||
}
|
||||
return { values: [], unreadable: true, present: true };
|
||||
}
|
||||
|
||||
export async function readDefaultKeyPassphraseForExport(
|
||||
keyPath: string,
|
||||
): Promise<DefaultKeyPassphraseExportRead> {
|
||||
const read = await readDefaultKeyPassphrasesForVerification(keyPath);
|
||||
if (read.values[0]) return { status: "readable", value: read.values[0] };
|
||||
return read.unreadable ? { status: "unreadable" } : { status: "missing" };
|
||||
}
|
||||
|
||||
export async function readRememberedKeyPassphrases(
|
||||
keyPath: string,
|
||||
keys: SSHKey[],
|
||||
): Promise<{ values: string[]; unreadable: boolean }> {
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
const values = new Set<string>();
|
||||
let unreadable = false;
|
||||
|
||||
const sideStore = await readDefaultKeyPassphrasesForVerification(keyPath);
|
||||
for (const value of sideStore.values) values.add(value);
|
||||
if (sideStore.unreadable) unreadable = true;
|
||||
|
||||
for (const key of keys) {
|
||||
if (
|
||||
key.source !== "reference"
|
||||
|| !key.filePath
|
||||
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
|| !key.passphrase
|
||||
) continue;
|
||||
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
|
||||
unreadable = true;
|
||||
} else {
|
||||
values.add(key.passphrase);
|
||||
}
|
||||
}
|
||||
|
||||
return { values: [...values], unreadable };
|
||||
}
|
||||
|
||||
export async function readExportableRememberedKeyPassphrases(
|
||||
keyPath: string,
|
||||
keys: SSHKey[],
|
||||
): Promise<{ values: string[]; unreadable: boolean }> {
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
const values = new Set<string>();
|
||||
let unreadable = false;
|
||||
|
||||
const hasExplicitOptOut = keys.some((key) => (
|
||||
key.source === "reference"
|
||||
&& key.savePassphrase === false
|
||||
&& key.filePath
|
||||
&& aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
));
|
||||
if (hasExplicitOptOut) return { values: [], unreadable: false };
|
||||
|
||||
const sideStore = await readDefaultKeyPassphrasesForVerification(keyPath);
|
||||
for (const value of sideStore.values) values.add(value);
|
||||
if (sideStore.unreadable) unreadable = true;
|
||||
|
||||
for (const key of keys) {
|
||||
if (
|
||||
key.source !== "reference"
|
||||
|| key.savePassphrase === false
|
||||
|| !key.filePath
|
||||
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
|| !key.passphrase
|
||||
) continue;
|
||||
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
|
||||
unreadable = true;
|
||||
} else {
|
||||
values.add(key.passphrase);
|
||||
}
|
||||
}
|
||||
|
||||
return { values: [...values], unreadable };
|
||||
}
|
||||
|
||||
function removeDefaultKeyPassphrasesUnlocked(keyPaths: string[]): void {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (!store) return;
|
||||
const pathKeys = matchingPathKeys(keyPaths);
|
||||
let changed = false;
|
||||
for (const storedPath of Object.keys(store)) {
|
||||
if (pathKeys.has(defaultKeyPassphrasePathKey(storedPath))) {
|
||||
delete store[storedPath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDefaultKeyPassphrases(keyPaths: string[]): Promise<void> {
|
||||
return runPassphraseMutation(async () => {
|
||||
removeDefaultKeyPassphrasesUnlocked(keyPaths);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeDefaultKeyPassphraseAliases(keyPaths: string[]): Promise<string[]> {
|
||||
return runPassphraseMutation(async () => {
|
||||
const aliases = Array.from(new Set((await Promise.all(
|
||||
keyPaths.map(resolveDefaultKeyPassphraseAliases),
|
||||
)).flat()));
|
||||
removeDefaultKeyPassphrasesUnlocked(aliases);
|
||||
return aliases;
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearRememberedKeyPassphrases(args: {
|
||||
keyPaths: string[];
|
||||
keyIds?: string[];
|
||||
getKeys: () => SSHKey[];
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
setCurrentKeys?: (keys: SSHKey[]) => void;
|
||||
}): Promise<void> {
|
||||
return runPassphraseMutation(async () => {
|
||||
const aliases = Array.from(new Set((await Promise.all(
|
||||
args.keyPaths.map(resolveDefaultKeyPassphraseAliases),
|
||||
)).flat()));
|
||||
removeDefaultKeyPassphrasesUnlocked(aliases);
|
||||
const currentKeys = args.getKeys();
|
||||
const withoutReferencePassphrases = clearReferenceKeyPassphrases(currentKeys, aliases);
|
||||
const updatedKeys = clearKeyPassphrasesByIds(withoutReferencePassphrases, args.keyIds);
|
||||
if (updatedKeys === currentKeys) return;
|
||||
args.setCurrentKeys?.(updatedKeys);
|
||||
await args.updateKeys(updatedKeys);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteVaultKey(args: {
|
||||
keyId: string;
|
||||
getKeys: () => SSHKey[];
|
||||
updateKeys: (keys: SSHKey[]) => void;
|
||||
}): Promise<void> {
|
||||
const keys = args.getKeys();
|
||||
const key = keys.find((candidate) => candidate.id === args.keyId);
|
||||
if (!key) return;
|
||||
|
||||
args.updateKeys(keys.filter((candidate) => candidate.id !== args.keyId));
|
||||
if (key.source !== "reference" || !key.filePath) return;
|
||||
|
||||
await runPassphraseMutation(async () => {
|
||||
const deletedAliases = await resolveDefaultKeyPassphraseAliases(key.filePath!);
|
||||
const deletedAliasKeys = matchingPathKeys(deletedAliases);
|
||||
const currentReferencePathKeys = matchingPathKeys(args.getKeys()
|
||||
.filter((candidate) => candidate.source === "reference" && candidate.filePath)
|
||||
.map((candidate) => candidate.filePath!));
|
||||
const pathStillReferenced = [...currentReferencePathKeys]
|
||||
.some((path) => deletedAliasKeys.has(path));
|
||||
if (!pathStillReferenced) {
|
||||
removeDefaultKeyPassphrasesUnlocked(deletedAliases);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function clearReferenceKeyPassphrases(keys: SSHKey[], keyPaths: string[]): SSHKey[] {
|
||||
const pathKeys = matchingPathKeys(keyPaths);
|
||||
let changed = false;
|
||||
const updated = keys.map((key) => {
|
||||
if (
|
||||
key.source === "reference"
|
||||
&& key.filePath
|
||||
&& pathKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
&& key.passphrase
|
||||
) {
|
||||
changed = true;
|
||||
return { ...key, passphrase: undefined, savePassphrase: false };
|
||||
}
|
||||
return key;
|
||||
});
|
||||
return changed ? updated : keys;
|
||||
}
|
||||
|
||||
export function clearKeyPassphrasesByIds(keys: SSHKey[], keyIds: string[] = []): SSHKey[] {
|
||||
if (keyIds.length === 0) return keys;
|
||||
const ids = new Set(keyIds);
|
||||
let changed = false;
|
||||
const updated = keys.map((key) => {
|
||||
if (ids.has(key.id) && key.passphrase) {
|
||||
changed = true;
|
||||
return { ...key, passphrase: undefined, savePassphrase: false };
|
||||
}
|
||||
return key;
|
||||
});
|
||||
return changed ? updated : keys;
|
||||
}
|
||||
|
||||
export function shouldUpdateReferenceKeyPassphrase(key?: SSHKey | null): boolean {
|
||||
return Boolean(
|
||||
key &&
|
||||
(!key.passphrase || isEncryptedCredentialPlaceholder(key.passphrase)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function rememberKeyPassphrase(args: {
|
||||
keyPath: string;
|
||||
passphrase: string;
|
||||
keys: SSHKey[];
|
||||
getKeys?: () => SSHKey[];
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
setCurrentKeys?: (keys: SSHKey[]) => void;
|
||||
}): Promise<void> {
|
||||
return runPassphraseMutation(() => rememberKeyPassphraseUnlocked(args));
|
||||
}
|
||||
|
||||
async function rememberKeyPassphraseUnlocked(args: {
|
||||
keyPath: string;
|
||||
passphrase: string;
|
||||
keys: SSHKey[];
|
||||
getKeys?: () => SSHKey[];
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
setCurrentKeys?: (keys: SSHKey[]) => void;
|
||||
}): Promise<void> {
|
||||
const { keyPath, passphrase, keys, getKeys, updateKeys, setCurrentKeys } = args;
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
const encrypted = await encryptField(passphrase) ?? passphrase;
|
||||
writeDefaultKeyPassphraseUnlocked(keyPath, encrypted, aliases);
|
||||
|
||||
let changed = false;
|
||||
const updated = (getKeys?.() ?? keys).map((key) => {
|
||||
if (
|
||||
key.source !== "reference"
|
||||
|| !key.filePath
|
||||
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
) return key;
|
||||
changed = true;
|
||||
return { ...key, passphrase, savePassphrase: true };
|
||||
});
|
||||
if (!changed) return;
|
||||
setCurrentKeys?.(updated);
|
||||
await updateKeys(updated);
|
||||
}
|
||||
|
||||
export type RememberImportedKeyPassphraseResult = "saved" | "conflict" | "unreadable";
|
||||
|
||||
function referenceKeyPassphraseFingerprint(keys: SSHKey[], aliasKeys: Set<string>): string {
|
||||
return JSON.stringify(keys
|
||||
.filter((key) => (
|
||||
key.source === "reference"
|
||||
&& key.filePath
|
||||
&& aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
))
|
||||
.map((key) => ({
|
||||
id: key.id,
|
||||
filePath: key.filePath,
|
||||
passphrase: key.passphrase,
|
||||
savePassphrase: key.savePassphrase,
|
||||
}))
|
||||
.sort((left, right) => left.id.localeCompare(right.id)));
|
||||
}
|
||||
|
||||
export async function rememberImportedKeyPassphrase(args: {
|
||||
keyPath: string;
|
||||
passphrase: string;
|
||||
keys: SSHKey[];
|
||||
getKeys?: () => SSHKey[];
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
setCurrentKeys?: (keys: SSHKey[]) => void;
|
||||
}): Promise<RememberImportedKeyPassphraseResult> {
|
||||
return runPassphraseMutation(async () => {
|
||||
const aliases = await resolveDefaultKeyPassphraseAliases(args.keyPath);
|
||||
const aliasKeys = matchingPathKeys(aliases);
|
||||
const currentKeys = args.getKeys?.() ?? args.keys;
|
||||
const initialKeyFingerprint = referenceKeyPassphraseFingerprint(currentKeys, aliasKeys);
|
||||
const existing = await readRememberedKeyPassphrases(args.keyPath, currentKeys);
|
||||
if (existing.unreadable) return "unreadable";
|
||||
if (existing.values.some((value) => value !== args.passphrase)) return "conflict";
|
||||
const encrypted = await encryptField(args.passphrase) ?? args.passphrase;
|
||||
const latestKeys = args.getKeys?.() ?? args.keys;
|
||||
if (referenceKeyPassphraseFingerprint(latestKeys, aliasKeys) !== initialKeyFingerprint) {
|
||||
return "conflict";
|
||||
}
|
||||
writeDefaultKeyPassphraseUnlocked(args.keyPath, encrypted, aliases);
|
||||
let changed = false;
|
||||
const updated = latestKeys.map((key) => {
|
||||
if (
|
||||
key.source !== "reference"
|
||||
|| !key.filePath
|
||||
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
||||
) return key;
|
||||
changed = true;
|
||||
return { ...key, passphrase: args.passphrase, savePassphrase: true };
|
||||
});
|
||||
if (changed) {
|
||||
args.setCurrentKeys?.(updated);
|
||||
await args.updateKeys(updated);
|
||||
}
|
||||
return "saved";
|
||||
});
|
||||
}
|
||||
73
application/i18n/I18nProvider.tsx
Normal file
73
application/i18n/I18nProvider.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { MESSAGES_BY_LOCALE } from './messages';
|
||||
|
||||
type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
export type I18nContextValue = {
|
||||
locale: string;
|
||||
resolvedLocale: string;
|
||||
t: (key: string, values?: InterpolationValues) => string;
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
const interpolate = (template: string, values?: InterpolationValues): string => {
|
||||
if (!values) return template;
|
||||
const replaceDoubleBraceToken = (match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return match;
|
||||
return String(v);
|
||||
};
|
||||
const replaceSingleBraceToken = (_match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v);
|
||||
};
|
||||
return template
|
||||
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
|
||||
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
||||
};
|
||||
|
||||
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
|
||||
const direct = MESSAGES_BY_LOCALE[resolvedLocale]?.[key];
|
||||
if (direct) return direct;
|
||||
const base = resolvedLocale.split('-')[0];
|
||||
const baseKey = Object.keys(MESSAGES_BY_LOCALE).find((k) => k === base || k.startsWith(`${base}-`));
|
||||
const baseHit = baseKey ? MESSAGES_BY_LOCALE[baseKey]?.[key] : undefined;
|
||||
if (baseHit) return baseHit;
|
||||
return MESSAGES_BY_LOCALE[DEFAULT_UI_LOCALE]?.[key];
|
||||
};
|
||||
|
||||
export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({
|
||||
locale,
|
||||
children,
|
||||
}) => {
|
||||
const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
return {
|
||||
locale,
|
||||
resolvedLocale,
|
||||
t: (key, values) => {
|
||||
const msg = resolveMessage(resolvedLocale, key) ?? key;
|
||||
return interpolate(msg, values);
|
||||
},
|
||||
};
|
||||
}, [locale, resolvedLocale]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
};
|
||||
|
||||
export const useI18n = (): I18nContextValue => {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
locale: DEFAULT_UI_LOCALE,
|
||||
resolvedLocale: DEFAULT_UI_LOCALE,
|
||||
t: (key) => key,
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
49
application/i18n/locales/cloudSyncConvergentLocales.test.ts
Normal file
49
application/i18n/locales/cloudSyncConvergentLocales.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import en from '../locales/en.ts';
|
||||
import ru from '../locales/ru.ts';
|
||||
import es from '../locales/es.ts';
|
||||
import zhCN from '../locales/zh-CN.ts';
|
||||
import zhTW from '../locales/zh-TW.ts';
|
||||
|
||||
const keys = [
|
||||
'cloudSync.convergent.title',
|
||||
'cloudSync.convergent.experimental',
|
||||
'cloudSync.convergent.desc',
|
||||
'cloudSync.convergent.active',
|
||||
'cloudSync.convergent.paused',
|
||||
'cloudSync.convergent.enabled',
|
||||
'cloudSync.convergent.preview.title',
|
||||
'cloudSync.convergent.preview.entities',
|
||||
'cloudSync.convergent.preview.providers',
|
||||
'cloudSync.convergent.preview.conflicts',
|
||||
'cloudSync.convergent.preview.compatibility',
|
||||
'cloudSync.convergent.preview.confirm',
|
||||
'cloudSync.convergent.preview.status.ready',
|
||||
'cloudSync.convergent.preview.status.empty',
|
||||
'cloudSync.convergent.preview.status.unavailable',
|
||||
'cloudSync.convergent.preview.status.blocked',
|
||||
'cloudSync.convergent.preview.schema',
|
||||
'cloudSync.convergent.field.presence',
|
||||
'cloudSync.convergent.field.position',
|
||||
'cloudSync.convergent.conflicts.title',
|
||||
'cloudSync.convergent.conflict.empty',
|
||||
'cloudSync.convergent.conflict.secretSet',
|
||||
'cloudSync.convergent.conflict.current',
|
||||
'cloudSync.convergent.conflict.choose',
|
||||
'cloudSync.convergent.conflict.resolved',
|
||||
'cloudSync.convergent.downgrade.desc',
|
||||
'cloudSync.convergent.downgrade.button',
|
||||
'cloudSync.convergent.downgrade.confirm',
|
||||
'cloudSync.convergent.downgrade.done',
|
||||
] as const;
|
||||
|
||||
test('convergent sync copy exists in every bundled locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
31
application/i18n/locales/cloudSyncStrategyLocales.test.ts
Normal file
31
application/i18n/locales/cloudSyncStrategyLocales.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import en from "../locales/en.ts";
|
||||
import ru from "../locales/ru.ts";
|
||||
import es from "../locales/es.ts";
|
||||
import zhCN from "../locales/zh-CN.ts";
|
||||
|
||||
const strategyKeys = [
|
||||
"cloudSync.strategy.title",
|
||||
"cloudSync.strategy.desc",
|
||||
"cloudSync.strategy.smartMerge",
|
||||
"cloudSync.strategy.smartMergeDesc",
|
||||
"cloudSync.strategy.preferCloud",
|
||||
"cloudSync.strategy.preferCloudDesc",
|
||||
"cloudSync.strategy.preferLocal",
|
||||
"cloudSync.strategy.preferLocalDesc",
|
||||
] as const;
|
||||
|
||||
test("cloud sync strategy copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
|
||||
for (const key of strategyKeys) {
|
||||
assert.equal(
|
||||
typeof messages[key],
|
||||
"string",
|
||||
`${locale} is missing ${key}`,
|
||||
);
|
||||
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
27
application/i18n/locales/codexSteerLocales.test.ts
Normal file
27
application/i18n/locales/codexSteerLocales.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const STEER_KEYS = [
|
||||
'ai.codex.steer.addInstruction',
|
||||
'ai.codex.steer.sending',
|
||||
'ai.codex.steer.placeholder',
|
||||
'ai.codex.steer.notSteerableReview',
|
||||
'ai.codex.steer.notSteerableCompact',
|
||||
'ai.codex.steer.busy',
|
||||
'ai.codex.steer.inactive',
|
||||
'ai.codex.steer.unsupported',
|
||||
'ai.codex.steer.failed',
|
||||
] as const;
|
||||
|
||||
test('Codex steering UI is localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = STEER_KEYS.filter(key => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing Codex steering labels`);
|
||||
}
|
||||
});
|
||||
20
application/i18n/locales/en.ts
Normal file
20
application/i18n/locales/en.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Messages } from './types';
|
||||
import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
import { enTerminalMessages } from './en/terminal';
|
||||
import { enAiMessages } from './en/ai';
|
||||
import { enSystemManagerMessages } from './en/systemManager';
|
||||
import { enScriptsMessages } from './en/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
const en: Messages = {
|
||||
...enCoreMessages,
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
...enAiMessages,
|
||||
...enSystemManagerMessages,
|
||||
...enScriptsMessages,
|
||||
};
|
||||
|
||||
export default en;
|
||||
672
application/i18n/locales/en/ai.ts
Normal file
672
application/i18n/locales/en/ai.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.chat.compactingContext': 'Compacting earlier context…',
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
|
||||
'ai.chat.contextUsage': 'Context usage: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent activity',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
'ai.chat.activity.webSearch': 'Web search',
|
||||
'ai.chat.activity.fileChanges': 'File changes',
|
||||
'ai.chat.activity.status.running': 'Running',
|
||||
'ai.chat.activity.status.completed': 'Completed',
|
||||
'ai.chat.activity.status.failed': 'Failed',
|
||||
'ai.chat.activity.file.add': 'Add',
|
||||
'ai.chat.activity.file.update': 'Update',
|
||||
'ai.chat.activity.file.delete': 'Delete',
|
||||
'ai.chat.activity.usage': 'Tokens',
|
||||
'ai.chat.activity.usage.input': 'in',
|
||||
'ai.chat.activity.usage.output': 'out',
|
||||
'ai.chat.activity.usage.cached': 'cached',
|
||||
'ai.chat.activity.usage.reasoning': 'reasoning',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
'ai.agents': 'Agents',
|
||||
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
|
||||
'ai.providers.add': 'Add Provider',
|
||||
'ai.providers.active': 'Active',
|
||||
'ai.providers.apiKeyConfigured': 'API key configured',
|
||||
'ai.providers.noApiKey': 'No API key',
|
||||
'ai.providers.configure': 'Configure',
|
||||
'ai.providers.remove': 'Remove',
|
||||
'ai.providers.name': 'Display Name',
|
||||
'ai.providers.name.placeholder': 'e.g. My Provider',
|
||||
'ai.providers.style': 'Protocol style',
|
||||
'ai.providers.style.anthropic': 'Anthropic-compatible',
|
||||
'ai.providers.style.openai': 'OpenAI-compatible',
|
||||
'ai.providers.style.google': 'Google-compatible',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
|
||||
'ai.providers.openaiApi': 'OpenAI API format',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': 'Chat Completions works with most OpenAI-compatible endpoints. Responses can raise cache hit rates on relays that support /v1/responses.',
|
||||
'ai.providers.icon.change': 'Change icon',
|
||||
'ai.providers.icon.upload': 'Upload image',
|
||||
'ai.providers.icon.reset': 'Reset',
|
||||
'ai.providers.icon.close': 'Close',
|
||||
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Please choose an image file.',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'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.baseUrl.ollamaHelp': 'Local Ollama: http://localhost:11434/v1 (no API key). Ollama Cloud: https://ollama.com/v1 plus your cloud API key.',
|
||||
'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',
|
||||
'ai.providers.contextWindow': 'Context window',
|
||||
'ai.providers.contextWindow.placeholder': 'e.g. 128000',
|
||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise NetMesh uses a safe default.',
|
||||
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.test': 'Test',
|
||||
'ai.providers.test.testing': 'Testing…',
|
||||
'ai.providers.test.ok': 'Connected ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Reached endpoint, but response looks incomplete ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Connected, but slow ({latency} ms)',
|
||||
'ai.providers.test.error': 'Failed ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Enter a Base URL first',
|
||||
'ai.providers.test.missingApiKey': 'Enter an API key first',
|
||||
'ai.providers.test.unavailable': 'Connection test is unavailable in this environment',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
'ai.providers.noMatchingModels': 'No matching models',
|
||||
'ai.providers.clickToLoadModels': 'Click to load models',
|
||||
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
|
||||
'ai.providers.advancedParams': 'Advanced Parameters',
|
||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||
'ai.providers.advancedParams.default': 'Provider default',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.appServer.title': 'Use Codex App Server',
|
||||
'ai.codex.appServer.experimental': 'Experimental',
|
||||
'ai.codex.appServer.description': 'Use the persistent Codex protocol for native approvals, sandbox controls, live models, and mid-turn questions. SDK remains the default.',
|
||||
'ai.codex.appServer.checking': 'Checking App Server support…',
|
||||
'ai.codex.appServer.available': 'App Server is available for this Codex CLI.',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'The live Codex model catalog is unavailable. Using the built-in model list.',
|
||||
'ai.codex.appServer.approval.allowSession': 'Allow for session',
|
||||
'ai.codex.appServer.userInput.title': 'Codex needs your input',
|
||||
'ai.codex.appServer.userInput.description': 'Answer these questions to continue the current turn.',
|
||||
'ai.codex.appServer.userInput.other': 'Enter another answer',
|
||||
'ai.codex.appServer.userInput.autoResolve': 'Codex will continue automatically if no answer is provided in time.',
|
||||
'ai.codex.appServer.userInput.skip': 'Skip',
|
||||
'ai.codex.appServer.userInput.submit': 'Continue',
|
||||
'ai.codex.steer.addInstruction': 'Add instruction',
|
||||
'ai.codex.steer.sending': 'Adding instruction…',
|
||||
'ai.codex.steer.placeholder': 'Add an instruction while Codex is working…',
|
||||
'ai.codex.steer.notSteerableReview': 'This Codex review turn cannot accept additional instructions. Your draft was kept.',
|
||||
'ai.codex.steer.notSteerableCompact': 'This Codex compaction turn cannot accept additional instructions. Your draft was kept.',
|
||||
'ai.codex.steer.busy': 'Another instruction is already being sent to Codex.',
|
||||
'ai.codex.steer.inactive': 'The Codex turn has already ended. Your draft was kept.',
|
||||
'ai.codex.steer.unsupported': 'In-flight instructions require the Codex App Server runtime.',
|
||||
'ai.codex.steer.failed': 'Codex could not accept the additional instruction. Your draft was kept.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
|
||||
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch NetMesh from a shell that has it) so Codex can authenticate.',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
|
||||
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Check',
|
||||
'ai.codex.resetPath': 'Reset',
|
||||
'ai.codex.openLogin': 'Open Login',
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
'ai.claude.path': 'Path:',
|
||||
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Authentication & config (optional)',
|
||||
'ai.claude.configDir': 'Config directory',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
|
||||
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
|
||||
'ai.claude.settings': 'Settings file',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (path, or inline {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Optional. A settings.json path or inline JSON, passed to the SDK as `settings`. Additive to — and independent of — the config directory above (merged on top, not a replacement).',
|
||||
'ai.claude.envVars': 'Environment variables',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
|
||||
'ai.claude.check': 'Check',
|
||||
'ai.claude.resetPath': 'Reset',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Uses the GitHub Copilot CLI. Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.detecting': 'Detecting...',
|
||||
'ai.copilot.detected': 'Detected',
|
||||
'ai.copilot.notFound': 'Not found',
|
||||
'ai.copilot.path': 'Path:',
|
||||
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
'ai.copilot.resetPath': 'Reset',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Uses the Cursor SDK or local Agent CLI login.',
|
||||
'ai.cursor.detecting': 'Detecting...',
|
||||
'ai.cursor.detected': 'Available',
|
||||
'ai.cursor.notFound': 'Unavailable',
|
||||
'ai.cursor.path': 'Runtime:',
|
||||
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor, or switch to CLI login.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK / Agent CLI was not detected.',
|
||||
'ai.cursor.installStatus': 'Cursor runtime',
|
||||
'ai.cursor.installed': 'Detected',
|
||||
'ai.cursor.notInstalled': 'Not detected',
|
||||
'ai.cursor.modeCli': 'CLI login',
|
||||
'ai.cursor.modeApiKey': 'API Key',
|
||||
'ai.cursor.modeCliHint': 'Uses your local `cursor-agent login` session and subscription Auto quota. Saved API Key is kept but not used in this mode.',
|
||||
'ai.cursor.modeApiKeyHint': 'Uses the metered Cursor API. CLI login is ignored while this mode is active.',
|
||||
'ai.cursor.cliLoginStatus': 'CLI login',
|
||||
'ai.cursor.cliLoginOk': 'Logged in',
|
||||
'ai.cursor.cliLoginAs': 'Logged in as {{email}}',
|
||||
'ai.cursor.cliLoginMissing': 'Not logged in',
|
||||
'ai.cursor.cliLoginHint': 'Run `cursor-agent login` in a terminal, then click Check.',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': 'Configured',
|
||||
'ai.cursor.apiKeyMissing': 'Missing',
|
||||
'ai.cursor.apiKeyFromEnv': 'From environment',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want NetMesh to override it.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'NetMesh will use the saved key here before CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Save',
|
||||
'ai.cursor.saved': 'Saved',
|
||||
'ai.cursor.showApiKey': 'Show API key',
|
||||
'ai.cursor.hideApiKey': 'Hide API key',
|
||||
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Check',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.codebuddy.detecting': 'Detecting...',
|
||||
'ai.codebuddy.detected': 'Detected',
|
||||
'ai.codebuddy.notFound': 'Not found',
|
||||
'ai.codebuddy.path': 'Path:',
|
||||
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Check',
|
||||
'ai.codebuddy.resetPath': 'Reset',
|
||||
'ai.codebuddy.configSection': 'Authentication & config (optional)',
|
||||
'ai.codebuddy.internetEnv': 'Internet Environment',
|
||||
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
|
||||
'ai.codebuddy.envVars': 'Environment variables',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.230)',
|
||||
'ai.codebuddy.effort': 'Reasoning Effort',
|
||||
'ai.codebuddy.effort.default': 'Default',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Low for simple commands to save tokens, High/XHigh for complex diagnostics.',
|
||||
'ai.codebuddy.maxTurns': 'Max Turns',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limits the maximum conversation turns per request to prevent runaway loops. Leave empty for default.',
|
||||
'ai.codebuddy.maxBudget': 'Max Budget (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Maximum spend (USD) per request. Stops automatically when exceeded. Leave empty for no limit.',
|
||||
'ai.codebuddy.sandbox': 'Sandbox Mode',
|
||||
'ai.codebuddy.sandbox.hint': 'Execute tool calls in a sandbox, restricting filesystem and network access.',
|
||||
'ai.codebuddy.fileCheckpointing': 'File Checkpointing',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Enable file operation checkpoints so AI file modifications can be rolled back.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy needs your input',
|
||||
'ai.codebuddy.elicitation.description': 'Review the request to continue the current turn.',
|
||||
'ai.codebuddy.elicitation.select': 'Select an option',
|
||||
'ai.codebuddy.elicitation.yes': 'Yes',
|
||||
'ai.codebuddy.elicitation.no': 'No',
|
||||
'ai.codebuddy.elicitation.decline': 'Decline',
|
||||
'ai.codebuddy.elicitation.accept': 'Continue',
|
||||
'ai.codebuddy.elicitation.validation.required': '{field} is required.',
|
||||
'ai.codebuddy.elicitation.validation.invalidType': '{field} has an invalid value.',
|
||||
'ai.codebuddy.elicitation.validation.integer': '{field} must be an integer.',
|
||||
'ai.codebuddy.elicitation.validation.notInteger': '{field} must be a whole number.',
|
||||
'ai.codebuddy.elicitation.validation.minimum': '{field} must be at least {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.maximum': '{field} must be at most {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.minLength': '{field} must contain at least {limit} characters.',
|
||||
'ai.codebuddy.elicitation.validation.maxLength': '{field} must contain at most {limit} characters.',
|
||||
'ai.codebuddy.elicitation.validation.minItems': 'Select at least {limit} options for {field}.',
|
||||
'ai.codebuddy.elicitation.validation.maxItems': 'Select at most {limit} options for {field}.',
|
||||
'ai.codebuddy.elicitation.validation.format': '{field} must match the {format} format.',
|
||||
'ai.codebuddy.elicitation.validation.option': 'Select a valid option for {field}.',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': 'Uses OpenCode via the official SDK. Configure providers and keys in OpenCode, then select it as an external coding agent.',
|
||||
'ai.opencode.detecting': 'Detecting...',
|
||||
'ai.opencode.detected': 'Detected',
|
||||
'ai.opencode.notFound': 'Not found',
|
||||
'ai.opencode.path': 'Path:',
|
||||
'ai.opencode.notFoundHint': 'Could not find opencode in PATH. Install it or specify the executable path below.',
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
|
||||
'ai.grok.detecting': 'Detecting...',
|
||||
'ai.grok.detected': 'Detected',
|
||||
'ai.grok.notFound': 'Not found',
|
||||
'ai.grok.path': 'Path:',
|
||||
'ai.grok.notFoundHint': 'Could not find grok in PATH. Install Grok Build CLI or specify the executable path below.',
|
||||
'ai.grok.customPathPlaceholder': 'e.g. /usr/local/bin/grok',
|
||||
'ai.grok.check': 'Check',
|
||||
'ai.grok.resetPath': 'Reset',
|
||||
'ai.grok.runtime.acp.title': 'Use Grok ACP (agent stdio)',
|
||||
'ai.grok.runtime.acp.default': 'Default',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'Talk to Grok over Agent Client Protocol (grok agent stdio). Injects NetMesh MCP on session/new. Turn off to use the original headless streaming-json CLI path.',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'Using headless streaming-json (grok -p --output-format streaming-json). Project .grok/config.toml is used for MCP injection.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
'ai.toolAccess.title': 'Tool Access',
|
||||
'ai.toolAccess.mode': 'NetMesh Access Mode',
|
||||
'ai.toolAccess.description': 'Choose how external agents access NetMesh sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local NetMesh skill and CLI commands.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.toolAccess.mcpPrompt.title': 'Prompt for your AI client',
|
||||
'ai.toolAccess.mcpPrompt.description': 'Paste this prompt into your AI client (Codex, Claude Code, …) and it will register NetMesh MCP for you.',
|
||||
'ai.toolAccess.mcpPrompt.enableHint': 'Turn on External MCP below to include the launcher path in this prompt.',
|
||||
'ai.toolAccess.skills.file': 'Skill file',
|
||||
'ai.toolAccess.skills.description': 'In Skills + CLI mode, agents are pointed to this local skill file automatically. The NetMesh CLI launcher path is provided to the agent in each session.',
|
||||
'ai.toolAccess.skills.unavailable': 'Skill file path unavailable',
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'External MCP',
|
||||
'ai.externalMcp.description': 'Expose NetMesh as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep NetMesh running while clients are connected.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
|
||||
'ai.externalMcp.mode': 'Availability mode',
|
||||
'ai.externalMcp.mode.temporary': 'Temporary',
|
||||
'ai.externalMcp.mode.persistent': 'Always on',
|
||||
'ai.externalMcp.mode.description': 'Temporary mode auto-disables after idle timeout. Always-on restores External MCP when NetMesh starts.',
|
||||
'ai.externalMcp.idleTimeout': 'Idle timeout',
|
||||
'ai.externalMcp.idleTimeout.description': 'In temporary mode, disable External MCP after this many minutes with no MCP operations.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.',
|
||||
'ai.externalMcp.silentSessions': 'Silent MCP sessions',
|
||||
'ai.externalMcp.silentSessions.description': 'Sessions opened by AI stay out of your tab bar and are not restored after restart. View them anytime from the tray panel.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'Automatically close sessions opened by an AI after this many minutes without terminal or file activity.',
|
||||
'ai.externalMcp.usage.title': 'How to use',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Turn on External MCP and keep NetMesh running.',
|
||||
'ai.externalMcp.usage.localhost': '2. Clients connect via the local launcher (127.0.0.1 only). Discovery is removed when you disable the switch.',
|
||||
'ai.externalMcp.usage.permissions': '3. Write operations follow Settings → AI → Safety (observer / confirm / auto) and the command blocklist.',
|
||||
'ai.externalMcp.usage.capabilities': '4. Full catalog tools are available: terminal, SFTP, Vault, and port forwarding. Secrets (passwords / private keys) are never returned.',
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Write permission mode',
|
||||
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs NetMesh write tools without NetMesh approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
|
||||
'ai.externalMcp.permissionMode.unknown': 'Unknown',
|
||||
'ai.externalMcp.discovery': 'Discovery',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': 'Unavailable',
|
||||
'ai.externalMcp.bridgeUnavailable': 'External MCP bridge unavailable',
|
||||
'ai.externalMcp.copy': 'Copy',
|
||||
'ai.externalMcp.copied': 'Copied',
|
||||
'ai.externalMcp.copyFailed': 'Copy failed. Try copying manually.',
|
||||
'ai.externalMcp.refresh': 'Refresh',
|
||||
'ai.externalMcp.clientConfiguration': 'Client configuration',
|
||||
'ai.externalMcp.clientConfiguration.description': 'Pick a client to one-click install, or copy CLI / config snippets.',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'CLI command',
|
||||
'ai.externalMcp.configSnippet': 'Config snippet',
|
||||
'ai.externalMcp.addToCodex': 'Add to Codex',
|
||||
'ai.externalMcp.addToClaude': 'Add to Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Add to Grok',
|
||||
'ai.externalMcp.codexAdded': 'Codex MCP entry added. Restart Codex or open a new Codex session.',
|
||||
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry added. Restart Claude Code or open a new Claude Code session.',
|
||||
'ai.externalMcp.grokAdded': 'Grok MCP entry added. Restart Grok or open a new Grok session.',
|
||||
'ai.externalMcp.installCodex': 'Install Codex separately, then click Refresh.',
|
||||
'ai.externalMcp.installClaude': 'Install Claude Code separately, then click Refresh.',
|
||||
'ai.externalMcp.installGrok': 'Install the Grok CLI separately, then click Refresh.',
|
||||
'ai.externalMcp.conflict.description': 'A NetMesh-external entry already exists and points elsewhere. Remove or edit it manually.',
|
||||
'ai.externalMcp.enableForLauncher': 'Enable External MCP to get a usable launcher path.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / other clients',
|
||||
'ai.externalMcp.cursor.description': 'Merge this into your MCP config (for example ~/.cursor/mcp.json). Do not replace the whole file if you already have other servers.',
|
||||
'ai.externalMcp.status.unavailable': 'Unavailable',
|
||||
'ai.externalMcp.status.disabled': 'Disabled',
|
||||
'ai.externalMcp.status.running': 'Running',
|
||||
'ai.externalMcp.status.starting': 'Starting',
|
||||
'ai.externalMcp.status.error': 'Error',
|
||||
'ai.externalMcp.status.configured': 'Configured',
|
||||
'ai.externalMcp.status.notConfigured': 'Not configured',
|
||||
'ai.externalMcp.status.checking': 'Checking',
|
||||
'ai.externalMcp.status.codexNotFound': 'Codex not found',
|
||||
'ai.externalMcp.status.claudeNotFound': 'Claude Code not found',
|
||||
'ai.externalMcp.status.grokNotFound': 'Grok not found',
|
||||
'ai.externalMcp.status.conflict': 'Conflict',
|
||||
'ai.userSkills.title': 'User Skills',
|
||||
'ai.userSkills.description': 'Open the NetMesh skills folder to add your own skill directories. NetMesh scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
|
||||
'ai.userSkills.openFolder': 'Open Skills Folder',
|
||||
'ai.userSkills.reload': 'Reload Skills',
|
||||
'ai.userSkills.location': 'Location',
|
||||
'ai.userSkills.loading': 'Scanning user skills...',
|
||||
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
|
||||
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
|
||||
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Quick Messages',
|
||||
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
|
||||
'ai.quickMessages.add': 'Add Quick Message',
|
||||
'ai.quickMessages.createTitle': 'New Quick Message',
|
||||
'ai.quickMessages.editTitle': 'Edit Quick Message',
|
||||
'ai.quickMessages.name': 'Name',
|
||||
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
|
||||
'ai.quickMessages.slug': 'Command',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Description (optional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
|
||||
'ai.quickMessages.content': 'Message content',
|
||||
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
|
||||
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
|
||||
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'Name is required.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
|
||||
'ai.quickMessages.error.contentRequired': 'Message content is required.',
|
||||
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
|
||||
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
'ai.chat.toolApproved': 'Approved',
|
||||
'ai.chat.toolApprovalHint': 'Enter once · Esc reject',
|
||||
'ai.chat.approve': 'Approve',
|
||||
'ai.chat.approveOnce': 'Once',
|
||||
'ai.chat.alwaysAllow': 'Always',
|
||||
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
|
||||
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.rawCommand': 'Command',
|
||||
'ai.chat.copyCommand': 'Copy',
|
||||
'ai.chat.commandCopied': 'Copied',
|
||||
'ai.chat.approvalSession': 'Session',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Reason',
|
||||
'ai.chat.approvalInvocation': 'Invocation',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
'ai.chat.recommendAllow': 'Allow',
|
||||
'ai.chat.recommendConfirm': 'Confirm',
|
||||
'ai.chat.recommendDeny': 'Deny',
|
||||
'ai.chat.exportConversation': 'Export conversation',
|
||||
'ai.chat.exportAs': 'Export As',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Plain Text',
|
||||
'ai.chat.thinking': 'Thinking',
|
||||
'ai.chat.thoughtFor': 'Thought for {duration}',
|
||||
'ai.chat.thought': 'Thought',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': 'Detected on this machine',
|
||||
'ai.chat.rescan': 'Re-scan',
|
||||
'ai.chat.permObserver': 'Observer',
|
||||
'ai.chat.permConfirm': 'Confirm',
|
||||
'ai.chat.permAuto': 'Auto',
|
||||
'ai.chat.permObserverDesc': 'Read only',
|
||||
'ai.chat.permConfirmDesc': 'Ask before writes',
|
||||
'ai.chat.permAutoDesc': 'Run freely',
|
||||
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
|
||||
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
|
||||
'ai.chat.placeholderDefault': 'Message Catty Agent...',
|
||||
'ai.chat.noModel': 'No model',
|
||||
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
|
||||
'ai.chat.selectProvider': 'Select provider',
|
||||
'ai.chat.selectProviderAndModel': 'Select provider and model',
|
||||
'ai.chat.selectModel': 'Select model',
|
||||
'ai.chat.searchModels': 'Search models',
|
||||
'ai.chat.providers': 'Providers',
|
||||
'ai.chat.models': 'Models',
|
||||
'ai.chat.pinned': 'Pinned',
|
||||
'ai.chat.useCustomModel': 'Use "{id}"',
|
||||
'ai.chat.thinkingLevel': 'Thinking',
|
||||
'ai.chat.thinkingOff': 'Off',
|
||||
'ai.chat.pinModel': 'Pin model',
|
||||
'ai.chat.unpinModel': 'Unpin model',
|
||||
'ai.chat.loadingModels': 'Loading models...',
|
||||
'ai.chat.noMatchingModels': 'No matching models',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.jumpNav': 'Jump to message',
|
||||
'ai.chat.jumpUntitled': '(empty message)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
'ai.chat.menuContext': 'Context',
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuMentionNote': 'Mention Note',
|
||||
'ai.chat.mentionNoteSearch': 'Search notes…',
|
||||
'ai.chat.mentionNoteEmpty': 'No matching notes',
|
||||
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
|
||||
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
|
||||
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
|
||||
'ai.chat.untitledNote': 'Untitled note',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
'ai.chat.slashSystemCommands': 'Commands',
|
||||
'ai.chat.slashQuickMessages': 'Quick messages',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Slash commands',
|
||||
'ai.chat.slashNoResults': 'No matching commands',
|
||||
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart NetMesh, or restart the Electron dev process, then try again.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Web Search',
|
||||
'ai.webSearch.enable': 'Enable Web Search',
|
||||
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
|
||||
'ai.webSearch.provider': 'Search Provider',
|
||||
'ai.webSearch.provider.description': 'Choose a web search API provider.',
|
||||
'ai.webSearch.apiKey': 'API Key',
|
||||
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
|
||||
'ai.webSearch.apiHost': 'API Host',
|
||||
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
|
||||
'ai.webSearch.maxResults': 'Max Results',
|
||||
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your NetMesh terminal sessions. Observer mode blocks write operations that go through NetMesh. External agent CLIs may still have their own local tools and approval flow.',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.auto': 'Auto - Execute freely',
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through NetMesh execution.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.responseIdleTimeout': 'Built-in AI Response Wait',
|
||||
'ai.safety.responseIdleTimeout.description': 'Cancel a built-in AI request after this many seconds without a new response. This setting does not control total response time or command execution.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands executed through NetMesh.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
'ai.safety.grants.title': 'Permission memory',
|
||||
'ai.safety.grants.heading': 'Confirm-mode allow rules',
|
||||
'ai.safety.grants.description': 'Confirm mode asks before running an operation. Saved rules automatically allow matching operations across all terminal sessions/nodes, and can be edited manually.',
|
||||
'ai.safety.grants.empty': 'No saved rules yet. Approve a tool with “Always allow”, or add one manually.',
|
||||
'ai.safety.grants.capability': 'Capability',
|
||||
'ai.safety.grants.sessionPattern': 'Session pattern',
|
||||
'ai.safety.grants.commandPattern': 'Command pattern (optional)',
|
||||
'ai.safety.grants.note': 'Note (optional)',
|
||||
'ai.safety.grants.add': 'Add rule',
|
||||
'ai.safety.grants.remove': 'Remove',
|
||||
'ai.safety.grants.export': 'Export JSON',
|
||||
'ai.safety.grants.import': 'Import JSON',
|
||||
'ai.safety.note': 'These safety settings are enforced for actions that go through NetMesh. External agent CLIs may also expose local tools that are governed by the agent itself.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Add Terminal',
|
||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'History',
|
||||
'terminal.layer.theme': 'Theme',
|
||||
'terminal.layer.notes': 'Notes',
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.closePane': 'Close split',
|
||||
'terminal.layer.resizeSplit': 'Resize split',
|
||||
'terminal.layer.splitHorizontal': 'Split top and bottom',
|
||||
'terminal.layer.splitVertical': 'Split left and right',
|
||||
'terminal.layer.openInNewSplit': 'Open in new split',
|
||||
'terminal.layer.hostTree.search': 'Search hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Search',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
|
||||
'terminal.layer.hostTree.newHost': 'New host',
|
||||
'terminal.layer.hostTree.newHostInGroup': 'New host in this group',
|
||||
'terminal.layer.hostTree.editHost': 'Edit host',
|
||||
'terminal.layer.hostTree.hostSavedNextConnection': 'Host updated. Connection settings will apply the next time you connect.',
|
||||
'terminal.layer.hostTree.newGroup': 'New group',
|
||||
'terminal.layer.hostTree.localShell': 'Local shell',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'No tags available',
|
||||
'terminal.layer.hostTree.clearTags': 'Clear selection',
|
||||
'terminal.layer.hostTree.collapse': 'Collapse host list',
|
||||
'terminal.layer.hostTree.expand': 'Expand host list',
|
||||
'terminal.layer.hostTree.empty': 'No hosts found',
|
||||
'terminal.layer.hostTree.details.host': 'Host',
|
||||
'terminal.layer.hostTree.details.user': 'User',
|
||||
'terminal.layer.hostTree.details.port': 'Port',
|
||||
'terminal.layer.hostTree.details.protocol': 'Protocol',
|
||||
'terminal.layer.hostTree.details.group': 'Group',
|
||||
'terminal.layer.hostTree.details.tags': 'Tags',
|
||||
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
|
||||
'topTabs.openQuickSwitcher': 'Open quick switcher',
|
||||
'topTabs.moreTabs': 'More tabs',
|
||||
'topTabs.aiAssistant': 'AI Assistant',
|
||||
'topTabs.newLocalTerminal': 'New Local Terminal',
|
||||
'topTabs.controlPanel': 'Quick controls',
|
||||
'topTabs.controlPanel.externalMcp': 'External MCP',
|
||||
'topTabs.controlPanel.theme': 'Theme',
|
||||
'topTabs.controlPanel.theme.light': 'Light',
|
||||
'topTabs.controlPanel.theme.dark': 'Dark',
|
||||
'topTabs.controlPanel.theme.system': 'System',
|
||||
'topTabs.externalMcp.enable': 'Enable External MCP',
|
||||
'topTabs.externalMcp.disable': 'Disable External MCP',
|
||||
'topTabs.windowOpacity': 'Window opacity',
|
||||
'topTabs.openSettings': 'Open Settings',
|
||||
'ai.chat.sessionHistory': 'Session history',
|
||||
'ai.chat.resizeInput': 'Drag to resize the message input',
|
||||
'ai.chat.attach': 'Attach',
|
||||
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
|
||||
'ai.chat.terminalSelectionLines': 'lines: {count}',
|
||||
'ai.chat.collapse': 'Collapse',
|
||||
'ai.chat.expand': 'Expand',
|
||||
'ai.chat.enableAgent': 'Enable {name}',
|
||||
'ai.chat.artifact.noteFallback': 'Vault note',
|
||||
'ai.chat.artifact.openNotes': 'Open Notes',
|
||||
'ai.chat.artifact.openHosts': 'Open Hosts',
|
||||
'ai.chat.artifact.notesSummary': '{count} notes in Vault',
|
||||
'ai.chat.artifact.hostsSummary': '{count} hosts in Vault',
|
||||
'ai.chat.artifact.hostsAdded': 'Added {count} hosts',
|
||||
'ai.chat.artifact.hostsPreview': 'Preview {count} hosts',
|
||||
'ai.chat.artifact.failed': 'Vault operation failed',
|
||||
'ai.chat.artifact.unavailableTitle': 'Unavailable',
|
||||
'ai.chat.artifact.noteMissing': 'This note is no longer in your Vault.',
|
||||
'ai.chat.artifact.hostMissing': 'This host is no longer in your Vault.',
|
||||
'ai.chat.artifact.snippetMissing': 'This snippet or script is no longer in your Vault.',
|
||||
'ai.chat.artifact.openSnippets': 'Open Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': '{count} snippets in Vault',
|
||||
'ai.chat.artifact.scriptsSummary': '{count} scripts in Vault',
|
||||
'ai.chat.artifact.snippetFallback': 'Vault snippet',
|
||||
'ai.chat.artifact.scriptFallback': 'Automation script',
|
||||
'ai.chat.artifact.scriptLanguage': '{language} script',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet deleted',
|
||||
'ai.chat.artifact.scriptDeleted': 'Script deleted',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet executed',
|
||||
'ai.chat.artifact.scriptStarted': 'Script run started',
|
||||
'ai.chat.artifact.scriptRunStatus': 'Script run {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} script runs',
|
||||
'ai.chat.artifact.scriptRunStopped': 'Script run stopped',
|
||||
'ai.chat.artifact.scriptRunPaused': 'Script run paused',
|
||||
'ai.chat.artifact.scriptRunResumed': 'Script run resumed',
|
||||
'ai.chat.artifact.scriptReference': 'nct API reference',
|
||||
'zmodem.waitingForRemote': 'Waiting for remote...',
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
};
|
||||
1156
application/i18n/locales/en/core.ts
Normal file
1156
application/i18n/locales/en/core.ts
Normal file
File diff suppressed because it is too large
Load Diff
133
application/i18n/locales/en/scripts.ts
Normal file
133
application/i18n/locales/en/scripts.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export const enScriptsMessages = {
|
||||
'scripts.meta.name': 'Name',
|
||||
'scripts.meta.language': 'Language',
|
||||
'scripts.meta.description': 'Description',
|
||||
'scripts.meta.descriptionPlaceholder': 'Optional notes about this script',
|
||||
'scripts.meta.trigger': 'Trigger',
|
||||
'scripts.meta.triggerPattern': 'Output pattern (regex)',
|
||||
'scripts.meta.code': 'Script',
|
||||
'scripts.trigger.manual': 'Manual run',
|
||||
'scripts.trigger.onConnect': 'Run on connect',
|
||||
'scripts.trigger.onOutput': 'Run on output match',
|
||||
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitForText or nct.screen.waitForRegex in script code.',
|
||||
'scripts.actions.save': 'Save',
|
||||
'scripts.actions.runNow': 'Run now',
|
||||
'scripts.actions.openEditor': 'Open editor',
|
||||
'scripts.actions.openEditorHint': 'Edit script in a larger window',
|
||||
'scripts.editor.modalTitle': 'Script editor',
|
||||
'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': '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)',
|
||||
'scripts.actions.runOnAllTabs': 'Run on all tabs',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} tab(s) skipped (password/sensitive input)',
|
||||
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
'scripts.running.status.paused': 'Paused',
|
||||
'scripts.running.status.completed': 'Completed',
|
||||
'scripts.running.status.failed': 'Failed',
|
||||
'scripts.running.waitingFor': 'Waiting for {pattern}',
|
||||
'scripts.running.waitingForLabel': 'Waiting for',
|
||||
'scripts.running.waitingForShellPrompt': 'shell prompt (# or $)',
|
||||
'scripts.running.lastSent': 'Sent: {command}',
|
||||
'scripts.recording.start': 'Start recording',
|
||||
'scripts.recording.active': 'Stop recording',
|
||||
'scripts.recording.startHint': 'Record actions in the focused terminal and generate nct script code',
|
||||
'scripts.recording.unavailableHint': 'Recording is not available here — use the scripts sidebar on the right',
|
||||
'scripts.recording.activeHint': 'Recording this terminal. Type commands as usual; click Stop or use the REC control in the toolbar to finish and save.',
|
||||
'scripts.recording.started': 'Recording started — operate in the terminal',
|
||||
'scripts.recording.noSession': 'Connect a terminal first (single tab or workspace)',
|
||||
'scripts.recording.alreadyActive': 'Another terminal is already recording',
|
||||
'scripts.recording.stop': 'Stop recording',
|
||||
'scripts.recording.pause': 'Pause recording',
|
||||
'scripts.recording.resume': 'Resume recording',
|
||||
'scripts.recording.saveTitle': 'Save recorded script',
|
||||
'scripts.recording.namePlaceholder': 'Script name',
|
||||
'scripts.recording.packagePlaceholder': 'Save to folder',
|
||||
'scripts.recording.rootPackage': 'Root',
|
||||
'scripts.recording.save': 'Save',
|
||||
'scripts.recording.saveAndEdit': 'Save and edit',
|
||||
'scripts.recording.helpTitle': 'How to record a script',
|
||||
'scripts.recording.helpIntro': 'Recording turns what you do in the terminal into a reusable automation script — useful for deploys, health checks, and other repetitive tasks.',
|
||||
'scripts.recording.helpStep1': 'Connect to a host. A standalone terminal tab or a terminal inside a workspace both work — open the scripts sidebar on the right.',
|
||||
'scripts.recording.helpStep2': 'Click Start recording. A red REC badge in the terminal toolbar means recording is active.',
|
||||
'scripts.recording.helpStep3': 'Type commands in the terminal as you normally would. Each Enter press is captured as one step.',
|
||||
'scripts.recording.helpStep4': 'When you are done, click Stop recording, or use the stop control next to REC in the terminal toolbar.',
|
||||
'scripts.recording.helpStep5': 'Name the script and save. Choose Save and edit if you want to fine-tune the generated code in the script editor.',
|
||||
'scripts.recording.helpTipsTitle': 'Tips',
|
||||
'scripts.recording.helpTip1': 'Pauses longer than 1 second between actions are recorded as wait time so playback is not too fast.',
|
||||
'scripts.recording.helpTip2': 'After each command, recording waits for a shell prompt (such as $ or #) before the next step.',
|
||||
'scripts.recording.helpTip3': 'Password input is marked sensitive and is not stored in plain text in the script.',
|
||||
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
|
||||
'scripts.dialog.title': 'Script',
|
||||
'scripts.dialog.ok': 'OK',
|
||||
'scripts.dialog.required': 'Required',
|
||||
'scripts.dialog.numberInvalid': 'Enter a valid number',
|
||||
'scripts.dialog.numberMin': 'Must be at least {min}',
|
||||
'scripts.dialog.numberMax': 'Must be at most {max}',
|
||||
'scripts.dialog.numberStep': 'Must use increments of {step}',
|
||||
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
|
||||
'scripts.dialog.retry': 'Retry',
|
||||
'scripts.dialog.skip': 'Skip',
|
||||
'scripts.dialog.abort': 'Abort',
|
||||
'scripts.running.stepProgress': 'Step {current} / {total}',
|
||||
'scripts.running.determinateProgress': '{label} {current}/{total}',
|
||||
'scripts.running.progressFallback': 'Progress',
|
||||
'scripts.running.operationsCount': '{count} operations',
|
||||
'scripts.running.opsPrefix': '',
|
||||
'scripts.running.opsSuffix': ' operations',
|
||||
'scripts.running.elapsedLabel': 'Elapsed',
|
||||
'scripts.running.lastSentLabel': 'Sent:',
|
||||
'scripts.running.elapsed': '{elapsed}',
|
||||
'scripts.running.completedSummary': 'Completed · {count} operations · {elapsed}',
|
||||
'scripts.running.dismissHint': 'Tap close to dismiss',
|
||||
'scripts.running.dismiss': 'Close',
|
||||
'scripts.running.viewLogs': 'View logs',
|
||||
'scripts.running.logTitle': '{name} · Run log',
|
||||
'scripts.running.logEmpty': 'No log output yet',
|
||||
'scripts.running.pause': 'Pause',
|
||||
'scripts.running.resume': 'Resume',
|
||||
'scripts.running.stop': 'Stop',
|
||||
'scripts.recording.saved': 'Script saved',
|
||||
'scripts.recording.savedNamed': 'Saved "{name}"',
|
||||
'scripts.observer.blocked': 'Observer mode blocks scripts that write to the terminal.',
|
||||
'vault.section.scripts': 'Scripts',
|
||||
'vault.nav.scripts': 'Scripts',
|
||||
'snippets.action.newScript': 'New automation script',
|
||||
'hostDetails.section.automation': 'Automation',
|
||||
'hostDetails.automation.loginScript': 'Login script',
|
||||
'hostDetails.automation.loginScriptPlaceholder': 'Select a script',
|
||||
'hostDetails.automation.none': 'None',
|
||||
'hostDetails.automation.outputTriggers': 'Output triggers',
|
||||
'hostDetails.automation.addTrigger': 'Add trigger',
|
||||
'hostDetails.automation.triggerPatternPlaceholder': 'Regex pattern',
|
||||
'hostDetails.automation.linkedScripts': 'Linked scripts',
|
||||
'hostDetails.automation.linkedScriptsEmpty': 'No scripts linked to this host yet.',
|
||||
'hostDetails.automation.linkScriptPlaceholder': 'Link existing script…',
|
||||
'hostDetails.automation.unlink': 'Unlink',
|
||||
'hostDetails.automation.equivalenceHint': 'Links add this host to the script\'s target list in Scripts. Both views stay in sync.',
|
||||
'hostDetails.automation.queueHint': 'Scripts run in order when this host connects. Global scripts run first, then this queue.',
|
||||
'hostDetails.automation.globalScripts': 'Global connect scripts',
|
||||
'hostDetails.automation.globalScriptsHint': 'Runs first on every connect. Reorder in Scripts library sort order.',
|
||||
'hostDetails.automation.connectQueue': 'This host\'s run queue',
|
||||
'hostDetails.automation.connectQueueEmpty': 'No connect scripts queued for this host yet.',
|
||||
'hostDetails.automation.addToQueuePlaceholder': 'Add script to queue…',
|
||||
'hostDetails.automation.moveUp': 'Move up',
|
||||
'hostDetails.automation.moveDown': 'Move down',
|
||||
'hostDetails.automation.removeFromQueue': 'Remove from queue',
|
||||
'hostDetails.automation.dragHandle': 'Drag to reorder',
|
||||
'hostDetails.automation.queueDragHint': 'Drag items to reorder the connect queue.',
|
||||
};
|
||||
261
application/i18n/locales/en/systemManager.ts
Normal file
261
application/i18n/locales/en/systemManager.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'System',
|
||||
|
||||
'systemManager.noSession': 'No active terminal session.',
|
||||
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.overview': 'Overview',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.ports': 'Ports',
|
||||
'systemManager.tabs.services': 'Services',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'System manager sections',
|
||||
'systemManager.popup.loading': 'Opening terminal…',
|
||||
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Failed to load processes',
|
||||
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
|
||||
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
|
||||
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
|
||||
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
|
||||
'systemManager.errors.actionFailed': 'Action failed',
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
'systemManager.overview.loading': 'Loading system overview…',
|
||||
'systemManager.overview.memory': 'Memory',
|
||||
'systemManager.overview.disk': 'Disk',
|
||||
'systemManager.overview.network': 'Network',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} cores',
|
||||
'systemManager.overview.load': 'Load',
|
||||
'systemManager.overview.uptime': 'Uptime',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}m',
|
||||
'systemManager.overview.system': 'System',
|
||||
'systemManager.overview.kernel': 'Kernel',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'SSH network latency',
|
||||
'systemManager.overview.cpuCores': 'CPU cores',
|
||||
'systemManager.overview.disks': 'Disks',
|
||||
'systemManager.overview.interfaces': 'Network interfaces',
|
||||
'systemManager.overview.topProcesses': 'Top memory processes',
|
||||
'systemManager.overview.noData': 'No data',
|
||||
'systemManager.overview.noDisks': 'No disk data',
|
||||
'systemManager.overview.noInterfaces': 'No interface data',
|
||||
'systemManager.overview.noTopProcesses': 'No process data',
|
||||
|
||||
'systemManager.processes.search': 'Search processes…',
|
||||
'systemManager.processes.command': 'Command',
|
||||
'systemManager.processes.user': 'User',
|
||||
'systemManager.processes.term': 'Terminate',
|
||||
'systemManager.processes.kill': 'Kill',
|
||||
'systemManager.processes.stop': 'Stop (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Continue (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Hang up (SIGHUP)',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
|
||||
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'All',
|
||||
'systemManager.processes.filter.running': 'Running',
|
||||
'systemManager.processes.ppid': 'Parent PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Virtual size',
|
||||
'systemManager.processes.elapsed': 'Elapsed',
|
||||
'systemManager.processes.stat': 'State',
|
||||
'systemManager.processes.meta': '{{count}} process(es)',
|
||||
'systemManager.processes.loading': 'Loading processes…',
|
||||
'systemManager.processes.loadingMore': 'Loading more processes…',
|
||||
'systemManager.processes.state.running': 'Running',
|
||||
'systemManager.processes.state.sleeping': 'Sleeping',
|
||||
'systemManager.processes.state.stopped': 'Stopped',
|
||||
'systemManager.processes.state.zombie': 'Zombie',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'MEM',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Command',
|
||||
'systemManager.processes.sort.user': 'User',
|
||||
|
||||
'systemManager.common.dismiss': 'Dismiss',
|
||||
'systemManager.common.checkingAvailability': 'Checking availability…',
|
||||
'systemManager.common.loading': 'Loading…',
|
||||
'systemManager.common.loadingDetails': 'Loading details…',
|
||||
'systemManager.common.loadingStats': 'Loading stats…',
|
||||
|
||||
'systemManager.tmux.new': 'New',
|
||||
'systemManager.tmux.search': 'Search sessions…',
|
||||
'systemManager.tmux.newSessionTitle': 'New tmux session',
|
||||
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Custom command',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
|
||||
'systemManager.tmux.pickSnippet': 'From snippets',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
|
||||
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Session name',
|
||||
'systemManager.tmux.newSessionCommand': 'Start command',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
|
||||
'systemManager.tmux.creating': 'Creating…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
|
||||
'systemManager.tmux.empty': 'No tmux sessions',
|
||||
'systemManager.tmux.attach': 'Attach',
|
||||
'systemManager.tmux.attached': 'Attached',
|
||||
'systemManager.tmux.detached': 'Detached',
|
||||
'systemManager.tmux.windows': '{{count}} window(s)',
|
||||
'systemManager.tmux.created': 'Created',
|
||||
'systemManager.tmux.activity': 'Activity',
|
||||
'systemManager.tmux.rename': 'Rename',
|
||||
'systemManager.tmux.detach': 'Detach all',
|
||||
'systemManager.tmux.killSession': 'Kill session',
|
||||
'systemManager.tmux.killServer': 'Kill server',
|
||||
'systemManager.tmux.loadingDetails': 'Loading details…',
|
||||
'systemManager.tmux.clients': 'Attached clients',
|
||||
'systemManager.tmux.windowList': 'Windows',
|
||||
'systemManager.tmux.newWindow': 'New window',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
'systemManager.tmux.panes': '{{count}} pane(s)',
|
||||
'systemManager.tmux.active': 'active',
|
||||
'systemManager.tmux.unnamedWindow': 'Unnamed window',
|
||||
'systemManager.tmux.unnamedPane': 'Unnamed pane',
|
||||
'systemManager.tmux.attachWindow': 'Attach to window',
|
||||
'systemManager.tmux.selectWindow': 'Select window',
|
||||
'systemManager.tmux.killWindow': 'Kill window',
|
||||
'systemManager.tmux.killPane': 'Kill pane',
|
||||
'systemManager.tmux.splitHorizontal': 'Split horizontal',
|
||||
'systemManager.tmux.splitVertical': 'Split vertical',
|
||||
'systemManager.tmux.sendKeys': 'Send keys',
|
||||
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Rename session',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Rename window',
|
||||
'systemManager.tmux.windowName': 'Window name',
|
||||
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
|
||||
'systemManager.tmux.meta': '{{count}} session(s)',
|
||||
|
||||
'systemManager.docker.title': 'Containers',
|
||||
'systemManager.docker.subTabs.containers': 'Containers',
|
||||
'systemManager.docker.subTabs.images': 'Images',
|
||||
'systemManager.docker.empty': 'No containers found',
|
||||
'systemManager.docker.imagesEmpty': 'No images found',
|
||||
'systemManager.docker.search': 'Search containers…',
|
||||
'systemManager.docker.searchImages': 'Search images…',
|
||||
'systemManager.docker.filter.all': 'All',
|
||||
'systemManager.docker.filter.running': 'Running',
|
||||
'systemManager.docker.filter.stopped': 'Stopped',
|
||||
'systemManager.docker.filter.paused': 'Paused',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Logs',
|
||||
'systemManager.docker.details': 'Details',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': 'Image inspect',
|
||||
'systemManager.docker.confirmRemove': 'Remove this container?',
|
||||
'systemManager.docker.confirmKill': 'Force kill this container?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
|
||||
'systemManager.docker.confirmPrune': 'Remove dangling images?',
|
||||
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
|
||||
'systemManager.docker.pause': 'Pause',
|
||||
'systemManager.docker.unpause': 'Unpause',
|
||||
'systemManager.docker.restart': 'Restart',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Container name',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': 'Tag',
|
||||
'systemManager.docker.tagRepoPrompt': 'Repository name',
|
||||
'systemManager.docker.tagNamePrompt': 'Tag name',
|
||||
'systemManager.docker.meta': '{{count}} container(s)',
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
'systemManager.ports.empty': 'No listening ports reported.',
|
||||
'systemManager.ports.search': 'Search ports…',
|
||||
'systemManager.ports.meta': '{{count}} listener(s)',
|
||||
'systemManager.ports.filter.all': 'All',
|
||||
'systemManager.ports.unknownProcess': 'Unknown process',
|
||||
'systemManager.ports.terminate': 'Terminate',
|
||||
'systemManager.ports.confirmTerminate': 'Send SIGTERM to process {{pid}} that holds this port?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl is not available on this host.',
|
||||
'systemManager.services.loading': 'Loading systemd services…',
|
||||
'systemManager.services.empty': 'No systemd services found.',
|
||||
'systemManager.services.search': 'Search services…',
|
||||
'systemManager.services.meta': '{{count}} service(s)',
|
||||
'systemManager.services.filter.all': 'All',
|
||||
'systemManager.services.filter.running': 'Running',
|
||||
'systemManager.services.filter.failed': 'Failed',
|
||||
'systemManager.services.filter.inactive': 'Inactive',
|
||||
'systemManager.services.start': 'Start',
|
||||
'systemManager.services.stop': 'Stop',
|
||||
'systemManager.services.restart': 'Restart',
|
||||
'systemManager.services.enable': 'Enable',
|
||||
'systemManager.services.disable': 'Disable',
|
||||
'systemManager.services.reload': 'Reload',
|
||||
'systemManager.services.scope.user': 'user',
|
||||
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
|
||||
'systemManager.gpu.loading': 'Loading accelerator stats…',
|
||||
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
|
||||
'systemManager.gpu.meta': '{{devices}} device(s) · {{processes}} process(es)',
|
||||
'systemManager.gpu.devices': 'Devices',
|
||||
'systemManager.gpu.processes': 'Compute processes',
|
||||
'systemManager.gpu.noProcesses': 'No compute processes reported.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Util',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Temperature',
|
||||
'systemManager.gpu.power': 'Power',
|
||||
'systemManager.gpu.fan': 'Fan {{value}}%',
|
||||
'systemManager.gpu.driver': 'Driver {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Status',
|
||||
'systemManager.inspect.image': 'Image',
|
||||
'systemManager.inspect.created': 'Created',
|
||||
'systemManager.inspect.started': 'Started',
|
||||
'systemManager.inspect.restartPolicy': 'Restart policy',
|
||||
'systemManager.inspect.command': 'Command',
|
||||
'systemManager.inspect.ports': 'Ports',
|
||||
'systemManager.inspect.networks': 'Networks',
|
||||
'systemManager.inspect.mounts': 'Mounts',
|
||||
'systemManager.inspect.env': 'Environment',
|
||||
'systemManager.inspect.labels': 'Labels',
|
||||
'systemManager.inspect.tags': 'Tags',
|
||||
'systemManager.inspect.digests': 'Digests',
|
||||
'systemManager.inspect.size': 'Size',
|
||||
'systemManager.inspect.platform': 'Platform',
|
||||
'systemManager.inspect.workdir': 'Working dir',
|
||||
'systemManager.inspect.exposedPorts': 'Exposed ports',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Hide JSON',
|
||||
};
|
||||
855
application/i18n/locales/en/terminal.ts
Normal file
855
application/i18n/locales/en/terminal.ts
Normal file
@@ -0,0 +1,855 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
|
||||
'terminal.passwordPicker.title': 'Saved passwords',
|
||||
'terminal.passwordPicker.empty': 'No saved passwords',
|
||||
// Network Device Mode auto-detection tip (session header)
|
||||
'terminal.networkDevice.tip.message': 'This looks like a network device. Enable Network Device Mode to send commands as-is (no shell wrapping).',
|
||||
'terminal.networkDevice.tip.action': 'Enable',
|
||||
'terminal.networkDevice.tip.dismiss': 'Dismiss',
|
||||
'terminal.networkDevice.tip.enabled': 'Network Device Mode enabled for {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Command history',
|
||||
'terminal.toolbar.configureOsc7': 'Configure directory tracking',
|
||||
'history.scope.label': 'History scope',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
'history.searchPlaceholder': 'Search history...',
|
||||
'history.loading': 'Loading remote history...',
|
||||
'history.meta.count': '{count} commands',
|
||||
'history.empty.noSession': 'Open a remote session to view its command history.',
|
||||
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
|
||||
'history.empty.noHistory': 'No command history found on this host.',
|
||||
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
|
||||
'history.action.refresh': 'Refresh',
|
||||
'history.action.retry': 'Retry',
|
||||
'history.action.paste': 'Paste to terminal',
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'history.action.delete': 'Delete from history',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal',
|
||||
'terminal.toolbar.search': 'Search',
|
||||
'terminal.toolbar.startSessionLog': 'Start session log',
|
||||
'terminal.toolbar.stopSessionLog': 'Stop session log',
|
||||
'terminal.toolbar.timestampsEnable': 'Show timestamps',
|
||||
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
|
||||
'terminal.toolbar.composeBar': 'Compose Bar',
|
||||
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
|
||||
'terminal.composeBar.pinnedCount': '{count} pinned',
|
||||
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.paneMagnification.magnify': 'Magnify Current Pane',
|
||||
'terminal.paneMagnification.restore': 'Restore Pane Layout',
|
||||
'terminal.paneMagnification.hint': 'Magnified',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.dragPane': 'Drag terminal pane',
|
||||
'terminal.toolbar.showActions': 'Show terminal actions',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
|
||||
'terminal.statusbar.copyHostname.label': 'Copy host address',
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
|
||||
'terminal.statusbar.disconnect.label': 'Disconnect',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Disconnect this session without closing the tab',
|
||||
'terminal.statusbar.reconnect.label': 'Reconnect',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
'terminal.serverStats.memBuffers': 'Buffers',
|
||||
'terminal.serverStats.memCached': 'Cache',
|
||||
'terminal.serverStats.memFree': 'Free',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap Used',
|
||||
'terminal.serverStats.swapFree': 'Swap Free',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
|
||||
'terminal.serverStats.disk': 'Disk Usage',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
'terminal.serverStats.network': 'Network Speed',
|
||||
'terminal.serverStats.latency': 'SSH network latency',
|
||||
'terminal.serverStats.networkDetails': 'Network Interfaces',
|
||||
'terminal.serverStats.noData': 'No data available',
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No files to upload',
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
'terminal.dragDrop.destinationUnknown': 'Could not determine the current terminal folder. Enable directory tracking, or open SFTP and choose an upload folder first.',
|
||||
'terminal.dragDrop.uploadCancelled': 'The upload was cancelled because the terminal connection changed or could not be reused. Drop the files again after reconnecting.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'This folder is not writable as the login user. Enable Sudo elevation in host settings, or go back to your user directory and drop again.',
|
||||
'terminal.search.placeholder': 'Search...',
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
|
||||
'terminal.menu.addSelectionToAI': 'Add to Conversation',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
'terminal.menu.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.menu.rename': 'Rename',
|
||||
'terminal.menu.detach': 'Detach from workspace',
|
||||
'terminal.menu.detachSession': 'Detach {name}',
|
||||
'terminal.clipboardImageUpload.noImage': 'Clipboard does not contain an image',
|
||||
'terminal.clipboardImageUpload.failed': 'Could not upload clipboard image',
|
||||
'terminal.osc7Setup.title': 'Configure directory tracking',
|
||||
'terminal.osc7Setup.desc': 'NetMesh will add OSC 7 prompt hooks for the current remote user. This helps SFTP follow the terminal directory after sudo or su.',
|
||||
'terminal.osc7Setup.targets': 'Possible files to update',
|
||||
'terminal.osc7Setup.command': 'Command to run',
|
||||
'terminal.osc7Setup.run': 'Run setup',
|
||||
'terminal.osc7Setup.running': 'Configuring...',
|
||||
'terminal.osc7Setup.configured': 'Directory tracking configured',
|
||||
'terminal.osc7Setup.failed': 'Directory tracking setup failed',
|
||||
'terminal.osc7Setup.sent': 'Directory tracking setup sent to terminal',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM send failed',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
|
||||
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
|
||||
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
'terminal.auth.password': 'Password',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': 'Username',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': 'Password',
|
||||
'terminal.auth.password.placeholder': 'Enter password',
|
||||
'terminal.auth.passphrase': 'Passphrase',
|
||||
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
|
||||
'terminal.auth.certificate': 'Certificate',
|
||||
'terminal.auth.selectKey': 'Select Key',
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Waiting for user input',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
|
||||
'terminal.progress.reconnecting': 'Reconnecting...',
|
||||
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
|
||||
'terminal.connection.chainOf': 'Chain {current} of {total}',
|
||||
'terminal.connection.showLogs': 'Show logs',
|
||||
'terminal.connection.hideLogs': 'Hide logs',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.connection.protocol.plugin': 'Plugin connection',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal does not currently support NetMesh proxy settings. Use SSH or remove the proxy for this host.',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal currently supports at most one jump host in NetMesh.',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Local Shell',
|
||||
'terminal.hostKey.unknownTitle': 'Confirm this host key',
|
||||
'terminal.hostKey.changedTitle': 'Host key changed',
|
||||
'terminal.hostKey.unknownDescription': 'The authenticity of {host} cannot be established yet.',
|
||||
'terminal.hostKey.changedDescription': 'The saved key for {host} no longer matches this server.',
|
||||
'terminal.hostKey.fingerprintLabel': '{keyType} fingerprint is SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': 'Saved fingerprint',
|
||||
'terminal.hostKey.unknownHint': 'Remember it if this fingerprint belongs to the server you expected.',
|
||||
'terminal.hostKey.changedHint': 'Only continue if you expected this host to change.',
|
||||
'terminal.hostKey.addAndContinue': 'Add and continue',
|
||||
'terminal.hostKey.updateAndContinue': 'Update and continue',
|
||||
'terminal.themeModal.title': 'Terminal Appearance',
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
'terminal.themeModal.tab.custom': 'Custom',
|
||||
'terminal.themeModal.globalTheme': 'Global Theme',
|
||||
'terminal.themeModal.globalFont': 'Global Font',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.fontWeight': 'Font Weight',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
'terminal.hiddenTheme.title': 'Current hidden theme',
|
||||
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
|
||||
'topTabs.toggleTheme.openSettings': 'Open Settings',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Custom Themes',
|
||||
'terminal.customTheme.yourThemes': 'Your Themes',
|
||||
'terminal.customTheme.new': 'New Theme',
|
||||
'terminal.customTheme.newDesc': 'Clone current theme and customize',
|
||||
'terminal.customTheme.newTitle': 'New Custom Theme',
|
||||
'terminal.customTheme.editTitle': 'Edit Theme',
|
||||
'terminal.customTheme.import': 'Import .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Import from iTerm2 color scheme file',
|
||||
'terminal.customTheme.importError': 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.',
|
||||
'terminal.customTheme.delete': 'Delete Theme',
|
||||
'terminal.customTheme.confirmDelete': 'Confirm Delete',
|
||||
'terminal.customTheme.name': 'Name',
|
||||
'terminal.customTheme.namePlaceholder': 'My Custom Theme',
|
||||
'terminal.customTheme.type': 'Type',
|
||||
'terminal.customTheme.group.general': 'General',
|
||||
'terminal.customTheme.group.normal': 'Normal Colors',
|
||||
'terminal.customTheme.group.bright': 'Bright Colors',
|
||||
'terminal.customTheme.color.background': 'Background',
|
||||
'terminal.customTheme.color.foreground': 'Foreground',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selection',
|
||||
'terminal.customTheme.color.black': 'Black',
|
||||
'terminal.customTheme.color.red': 'Red',
|
||||
'terminal.customTheme.color.green': 'Green',
|
||||
'terminal.customTheme.color.yellow': 'Yellow',
|
||||
'terminal.customTheme.color.blue': 'Blue',
|
||||
'terminal.customTheme.color.magenta': 'Magenta',
|
||||
'terminal.customTheme.color.cyan': 'Cyan',
|
||||
'terminal.customTheme.color.white': 'White',
|
||||
'terminal.customTheme.color.brightBlack': 'Bright Black',
|
||||
'terminal.customTheme.color.brightRed': 'Bright Red',
|
||||
'terminal.customTheme.color.brightGreen': 'Bright Green',
|
||||
'terminal.customTheme.color.brightYellow': 'Bright Yellow',
|
||||
'terminal.customTheme.color.brightBlue': 'Bright Blue',
|
||||
'terminal.customTheme.color.brightMagenta': 'Bright Magenta',
|
||||
'terminal.customTheme.color.brightCyan': 'Bright Cyan',
|
||||
'terminal.customTheme.color.brightWhite': 'Bright White',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'End-to-End Encrypted Sync',
|
||||
'cloudSync.gate.desc':
|
||||
'Your data is encrypted locally before syncing. Cloud providers never see your plaintext data. Set a master key to enable secure sync.',
|
||||
'cloudSync.gate.masterKey': 'Master Key',
|
||||
'cloudSync.gate.confirmMasterKey': 'Confirm Master Key',
|
||||
'cloudSync.gate.placeholder': 'Enter a strong password',
|
||||
'cloudSync.gate.confirmPlaceholder': 'Confirm your password',
|
||||
'cloudSync.gate.mismatch': 'Passwords do not match',
|
||||
'cloudSync.gate.warning':
|
||||
'I understand that if I forget my master key, my data cannot be recovered. There is no password reset.',
|
||||
'cloudSync.gate.enableVault': 'Enable Encrypted Vault',
|
||||
'cloudSync.gate.enabledToast': 'Encrypted vault enabled',
|
||||
'cloudSync.gate.setupFailed': 'Failed to set up master key',
|
||||
'cloudSync.passwordStrength.tooShort': 'Too short',
|
||||
'cloudSync.passwordStrength.weak': 'Weak',
|
||||
'cloudSync.passwordStrength.moderate': 'Moderate',
|
||||
'cloudSync.passwordStrength.strong': 'Strong',
|
||||
'cloudSync.passwordStrength.veryStrong': 'Very Strong',
|
||||
'cloudSync.provider.notConnected': 'Not connected',
|
||||
'cloudSync.provider.sync': 'Sync',
|
||||
'cloudSync.provider.connect': 'Connect',
|
||||
'cloudSync.provider.connecting': 'Connecting...',
|
||||
'cloudSync.provider.disconnect': 'Disconnect',
|
||||
'cloudSync.provider.disconnect.confirmTitle': 'Disconnect "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Your local vault stays on this computer.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Disconnect',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'S3 Compatible',
|
||||
'cloudSync.provider.s3.desc': 'Connect to S3-compatible object storage',
|
||||
'cloudSync.provider.comingSoon': 'Coming soon',
|
||||
'cloudSync.webdav.title': 'WebDAV Settings',
|
||||
'cloudSync.webdav.desc': 'Configure a WebDAV endpoint for encrypted sync.',
|
||||
'cloudSync.webdav.endpoint': 'Endpoint URL',
|
||||
'cloudSync.webdav.authType': 'Auth Type',
|
||||
'cloudSync.webdav.auth.basic': 'Basic',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
'cloudSync.webdav.auth.token': 'Token',
|
||||
'cloudSync.webdav.username': 'Username',
|
||||
'cloudSync.webdav.password': 'Password',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': 'Show secret',
|
||||
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
|
||||
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
|
||||
'cloudSync.webdav.validation.token': 'Token is required.',
|
||||
'cloudSync.s3.title': 'S3 Settings',
|
||||
'cloudSync.s3.desc': 'Connect to S3-compatible object storage for encrypted sync.',
|
||||
'cloudSync.s3.endpoint': 'Endpoint URL',
|
||||
'cloudSync.s3.region': 'Region',
|
||||
'cloudSync.s3.bucket': 'Bucket',
|
||||
'cloudSync.s3.accessKeyId': 'Access Key ID',
|
||||
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
|
||||
'cloudSync.s3.sessionToken': 'Session Token (optional)',
|
||||
'cloudSync.s3.prefix': 'Key Prefix (optional)',
|
||||
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
|
||||
'cloudSync.s3.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
|
||||
'cloudSync.s3.showSecret': 'Show secrets',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
|
||||
'cloudSync.smb.title': 'SMB Settings',
|
||||
'cloudSync.smb.desc': 'Connect to an SMB/CIFS file share for encrypted sync.',
|
||||
'cloudSync.smb.share': 'Share Path',
|
||||
'cloudSync.smb.username': 'Username',
|
||||
'cloudSync.smb.password': 'Password',
|
||||
'cloudSync.smb.domain': 'Domain (optional)',
|
||||
'cloudSync.smb.domainPlaceholder': 'e.g., WORKGROUP',
|
||||
'cloudSync.smb.port': 'Port (optional)',
|
||||
'cloudSync.smb.showSecret': 'Show password',
|
||||
'cloudSync.smb.validation.share': 'Share path is required.',
|
||||
'cloudSync.smb.validation.port': 'Port must be a number between 1 and 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB connected successfully',
|
||||
'cloudSync.connect.smb.failedTitle': 'SMB connection failed',
|
||||
'cloudSync.provider.smb': 'SMB Share',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV connected successfully',
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
|
||||
'cloudSync.connect.s3.success': 'S3 connected successfully',
|
||||
'cloudSync.connect.s3.failedTitle': 'S3 connection failed',
|
||||
'cloudSync.connect.plugin.success': 'Plugin sync provider connected successfully',
|
||||
'cloudSync.connect.plugin.failedTitle': 'Plugin sync connection failed',
|
||||
'cloudSync.pluginConfig.title': 'Configure {name}',
|
||||
'cloudSync.pluginConfig.desc': 'Enter the JSON configuration required by this plugin sync provider.',
|
||||
'cloudSync.pluginConfig.label': 'Provider configuration (JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': 'Configuration must be valid JSON.',
|
||||
'cloudSync.pluginConfig.schemaInvalid': 'Configuration does not match the provider schema.',
|
||||
'cloudSync.lastSync.never': 'Never',
|
||||
'cloudSync.lastSync.justNow': 'Just now',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} min ago',
|
||||
'cloudSync.changeKey': 'Change Key',
|
||||
'cloudSync.providers.title': 'Cloud Providers',
|
||||
'cloudSync.syncAll': 'Sync All Connected Providers',
|
||||
'cloudSync.autoSync.title': 'Auto-sync',
|
||||
'cloudSync.autoSync.desc': 'Automatically sync when changes are made',
|
||||
'cloudSync.strategy.title': 'Sync strategy',
|
||||
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
|
||||
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if NetMesh cannot decide safely, ask you to choose.',
|
||||
'cloudSync.strategy.preferCloud': 'Cloud wins',
|
||||
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
|
||||
'cloudSync.strategy.preferLocal': 'Local wins',
|
||||
'cloudSync.strategy.preferLocalDesc': 'When both sides changed, upload the local version and replace cloud changes.',
|
||||
'cloudSync.convergent.title': 'Convergent multi-device sync',
|
||||
'cloudSync.convergent.experimental': 'Experimental',
|
||||
'cloudSync.convergent.desc': 'Uses an encrypted CRDT replica to preserve offline edits, concurrent deletions, and changes from every connected provider.',
|
||||
'cloudSync.convergent.active': 'CRDT v2 is active. Provider writes are verified after upload.',
|
||||
'cloudSync.convergent.paused': 'CRDT v2 is paused on this device; cloud metadata is retained.',
|
||||
'cloudSync.convergent.enabled': 'Convergent sync enabled',
|
||||
'cloudSync.convergent.preview.title': 'Migration preview',
|
||||
'cloudSync.convergent.preview.entities': 'Entities',
|
||||
'cloudSync.convergent.preview.providers': 'Providers',
|
||||
'cloudSync.convergent.preview.conflicts': 'Conflicts',
|
||||
'cloudSync.convergent.preview.compatibility': 'A complete v1 snapshot remains in every encrypted payload for older clients.',
|
||||
'cloudSync.convergent.preview.confirm': 'Create CRDT replica',
|
||||
'cloudSync.convergent.preview.status.ready': 'Ready',
|
||||
'cloudSync.convergent.preview.status.empty': 'Empty',
|
||||
'cloudSync.convergent.preview.status.unavailable': 'Unavailable',
|
||||
'cloudSync.convergent.preview.status.blocked': 'Blocked',
|
||||
'cloudSync.convergent.preview.schema': 'schema',
|
||||
'cloudSync.convergent.field.presence': 'presence',
|
||||
'cloudSync.convergent.field.position': 'position',
|
||||
'cloudSync.convergent.conflicts.title': 'Field conflicts ({count})',
|
||||
'cloudSync.convergent.conflict.empty': 'Empty / deleted',
|
||||
'cloudSync.convergent.conflict.secretSet': 'Secret is set',
|
||||
'cloudSync.convergent.conflict.current': 'current winner',
|
||||
'cloudSync.convergent.conflict.choose': 'Choose',
|
||||
'cloudSync.convergent.conflict.resolved': 'Conflict resolved and synchronized',
|
||||
'cloudSync.convergent.downgrade.desc': 'Replace v2 files on every connected provider with a legacy snapshot.',
|
||||
'cloudSync.convergent.downgrade.button': 'Downgrade',
|
||||
'cloudSync.convergent.downgrade.confirm': 'Downgrade every connected provider to legacy sync? This removes CRDT metadata after write verification.',
|
||||
'cloudSync.convergent.downgrade.done': 'Convergent sync downgraded',
|
||||
'cloudSync.status.title': 'Sync Status',
|
||||
'cloudSync.status.localVersion': 'Local Version',
|
||||
'cloudSync.status.remoteVersion': 'Remote Version',
|
||||
'cloudSync.history.title': 'Sync History',
|
||||
'cloudSync.history.upload': 'Upload',
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'NetMesh keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups NetMesh should keep.',
|
||||
'cloudSync.localBackups.maxCount': 'Max backups',
|
||||
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
|
||||
'cloudSync.localBackups.empty': 'No local backups yet.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes',
|
||||
'cloudSync.localBackups.restore': 'Restore',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Restore',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
|
||||
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to NetMesh, so local backups cannot be written safely. Install NetMesh on a system with a supported keychain to enable the local backup history.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Master key required',
|
||||
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
|
||||
'cloudSync.revisionHistory.viewButton': 'History',
|
||||
'cloudSync.revisionHistory.title': 'Vault Version History',
|
||||
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
|
||||
'cloudSync.revisionHistory.empty': 'No revisions found.',
|
||||
'cloudSync.revisionHistory.current': 'Current',
|
||||
'cloudSync.revisionHistory.revision': 'Revision',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Revision Contents',
|
||||
'cloudSync.revisionHistory.device': 'Device',
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Keys',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.notes': 'Notes',
|
||||
'cloudSync.revisionHistory.identities': 'Identities',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
|
||||
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Revision not found or does not contain vault data.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'Cannot decrypt this revision. It may have been encrypted with a different master password.',
|
||||
'cloudSync.changeKey.title': 'Change Master Key',
|
||||
'cloudSync.changeKey.current': 'Current Master Key',
|
||||
'cloudSync.changeKey.new': 'New Master Key',
|
||||
'cloudSync.changeKey.confirmNew': 'Confirm New Master Key',
|
||||
'cloudSync.changeKey.currentPlaceholder': 'Enter current master key',
|
||||
'cloudSync.changeKey.newPlaceholder': 'Enter new master key',
|
||||
'cloudSync.changeKey.confirmPlaceholder': 'Confirm new master key',
|
||||
'cloudSync.changeKey.fillAll': 'Please fill in all fields',
|
||||
'cloudSync.changeKey.minLength': 'New master key must be at least 8 characters',
|
||||
'cloudSync.changeKey.notMatch': 'New master keys do not match',
|
||||
'cloudSync.changeKey.incorrectCurrent': 'Incorrect current master key',
|
||||
'cloudSync.changeKey.failed': 'Failed to change master key',
|
||||
'cloudSync.changeKey.desc': 'This will re-encrypt your vault. Make sure you remember the new key.',
|
||||
'cloudSync.changeKey.showKeys': 'Show keys',
|
||||
'cloudSync.changeKey.updatedToast': 'Master key updated',
|
||||
'cloudSync.changeKey.updateButton': 'Update Key',
|
||||
'cloudSync.unlock.title': 'Enter Master Key',
|
||||
'cloudSync.unlock.masterKey': 'Master Key',
|
||||
'cloudSync.unlock.desc':
|
||||
'Enter your master key once to enable encrypted sync. It will be stored securely using your OS keychain.',
|
||||
'cloudSync.unlock.placeholder': 'Enter your master key',
|
||||
'cloudSync.unlock.empty': 'Please enter your master key',
|
||||
'cloudSync.unlock.incorrect': 'Incorrect master key',
|
||||
'cloudSync.unlock.failed': 'Failed to unlock vault',
|
||||
'cloudSync.unlock.showKey': 'Show key',
|
||||
'cloudSync.unlock.notNow': 'Not now',
|
||||
'cloudSync.unlock.readyToast': 'Vault ready',
|
||||
'cloudSync.unlock.unlockButton': 'Unlock',
|
||||
'cloudSync.header.vaultReady': 'Vault ready',
|
||||
'cloudSync.header.preparingVault': 'Preparing vault...',
|
||||
'cloudSync.header.providersConnected': '{count} provider(s) connected',
|
||||
'cloudSync.githubFlow.title': 'Connect to GitHub',
|
||||
'cloudSync.githubFlow.desc': 'Copy the code below and enter it on GitHub to authorize NetMesh.',
|
||||
'cloudSync.githubFlow.copyCode': 'Copy code',
|
||||
'cloudSync.githubFlow.copied': 'Copied!',
|
||||
'cloudSync.githubFlow.openGitHub': 'Open GitHub',
|
||||
'cloudSync.githubFlow.waiting': 'Waiting for authorization...',
|
||||
'cloudSync.conflict.title': 'Version conflict detected',
|
||||
'cloudSync.conflict.desc': 'Choose which version to keep',
|
||||
'cloudSync.conflict.local': 'LOCAL',
|
||||
'cloudSync.conflict.cloud': 'CLOUD',
|
||||
'cloudSync.conflict.detailsTitle': 'Changed data',
|
||||
'cloudSync.conflict.detailsCounts': 'Local {local} · Cloud {cloud} · Conflicts {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': 'Hosts',
|
||||
'cloudSync.conflict.entity.keys': 'Keys',
|
||||
'cloudSync.conflict.entity.identities': 'Identities',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Proxy profiles',
|
||||
'cloudSync.conflict.entity.snippets': 'Snippets',
|
||||
'cloudSync.conflict.entity.notes': 'Notes',
|
||||
'cloudSync.conflict.entity.noteGroups': 'Note groups',
|
||||
'cloudSync.conflict.entity.customGroups': 'Groups',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
|
||||
'cloudSync.conflict.entity.settings': 'Settings',
|
||||
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
|
||||
'cloudSync.conflict.useCloud': 'Download cloud (overwrite local)',
|
||||
'cloudSync.connect.browserContinue': 'Complete authorization in browser',
|
||||
'cloudSync.connect.browserCancelled': 'Previous browser authorization was cancelled',
|
||||
'cloudSync.connect.github.success': 'GitHub connected successfully',
|
||||
'cloudSync.connect.github.failedTitle': 'GitHub connection failed',
|
||||
'cloudSync.connect.github.timeout': 'GitHub connection timed out. Check your network or proxy settings.',
|
||||
'cloudSync.connect.github.networkError': 'Unable to reach GitHub. Check your network or proxy settings.',
|
||||
'cloudSync.connect.google.failedTitle': 'Google connection failed',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'OneDrive connection failed',
|
||||
'cloudSync.sync.success': 'Synced to {provider}',
|
||||
'cloudSync.sync.failed': 'Sync failed',
|
||||
'cloudSync.sync.failedTitle': 'Sync failed',
|
||||
'cloudSync.sync.errorTitle': 'Sync error',
|
||||
'cloudSync.resolve.downloaded': 'Downloaded cloud data',
|
||||
'cloudSync.resolve.uploaded': 'Uploaded local data',
|
||||
'cloudSync.resolve.failedTitle': 'Conflict resolution failed',
|
||||
'cloudSync.clearLocal.title': 'Clear Local Data',
|
||||
'cloudSync.clearLocal.desc': 'Reset local version and sync history. Next sync will download from cloud.',
|
||||
'cloudSync.clearLocal.button': 'Clear',
|
||||
'cloudSync.clearLocal.dialog.title': 'Clear Local Vault Data?',
|
||||
'cloudSync.clearLocal.dialog.desc': 'This will reset local version to 0 and clear sync history. Your next sync will download data from the cloud, replacing local data.',
|
||||
'cloudSync.clearLocal.dialog.cancel': 'Cancel',
|
||||
'cloudSync.clearLocal.dialog.confirm': 'Clear Local Data',
|
||||
'cloudSync.clearLocal.toast.title': 'Local data cleared',
|
||||
'cloudSync.clearLocal.toast.desc': 'Local version reset to 0. Sync to download from cloud.',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': 'KEY',
|
||||
'keychain.filter.certificate': 'CERTIFICATE',
|
||||
'keychain.action.generateKey': 'Generate Key',
|
||||
'keychain.action.importKey': 'Import Key',
|
||||
'keychain.action.newIdentity': 'New Identity',
|
||||
'keychain.action.importCertificate': 'Import Certificate',
|
||||
'keychain.view.grid': 'Grid',
|
||||
'keychain.view.list': 'List',
|
||||
'keychain.section.keys': 'Keys',
|
||||
'keychain.section.identities': 'Identities',
|
||||
'keychain.count.items': '{count} items',
|
||||
'keychain.empty.title': 'Set up your keys',
|
||||
'keychain.empty.desc': 'Import or generate SSH keys for secure authentication.',
|
||||
'keychain.panel.generateKey': 'Generate Key',
|
||||
'keychain.panel.newKey': 'New Key',
|
||||
'keychain.panel.keyDetails': 'Key Details',
|
||||
'keychain.panel.editKey': 'Edit Key',
|
||||
'keychain.panel.editIdentity': 'Edit Identity',
|
||||
'keychain.panel.newIdentity': 'New Identity',
|
||||
'keychain.panel.keyExport': 'Key Export',
|
||||
'keychain.validation.labelRequired': 'Please enter a label for the key',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'Label and private key are required',
|
||||
'keychain.validation.labelAndUsernameRequired': 'Label and username are required',
|
||||
'keychain.error.generationUnavailable':
|
||||
'Key generation not available - please ensure the app is running in Electron',
|
||||
'keychain.error.generateKeyPairFailed': 'Failed to generate key pair',
|
||||
'keychain.error.generateKeyFailed': 'Failed to generate key',
|
||||
'keychain.error.keyGenerationTitle': 'Key Generation',
|
||||
'keychain.export.exportTo': 'Export to *',
|
||||
'keychain.export.selectHost': 'Select Host',
|
||||
'keychain.export.location': 'Location ~ $1 *',
|
||||
'keychain.export.filename': 'Filename ~ $2 *',
|
||||
'keychain.export.note':
|
||||
'Key export currently supports only {unix} systems. Use the {advanced} section to customize the export script.',
|
||||
'keychain.export.script': 'Script *',
|
||||
'keychain.export.scriptPlaceholder': 'Export script...',
|
||||
'keychain.export.missingCredentials':
|
||||
'Host has no saved password or key. Please add password credentials to the host first.',
|
||||
'keychain.export.successTitle': 'Export Successful',
|
||||
'keychain.export.successMessage': 'Public key exported and attached to {host}',
|
||||
'keychain.export.failedTitle': 'Export Failed',
|
||||
'keychain.export.failedMessage': 'Failed to export key: {error}',
|
||||
'keychain.export.failedPrefix': 'Export failed: {error}',
|
||||
'keychain.export.exitCode': 'Command exited with code {code}',
|
||||
'keychain.export.exporting': 'Exporting...',
|
||||
'keychain.export.exportAndAttach': 'Export and Attach',
|
||||
'keychain.export.title': 'Key export',
|
||||
'keychain.export.exportToRequired': 'Export to *',
|
||||
'keychain.export.selectHostPlaceholder': 'Select a host...',
|
||||
'keychain.export.locationLabel': 'Location ~ $1 *',
|
||||
'keychain.export.filenameLabel': 'Filename ~ $2 *',
|
||||
'keychain.export.advanced': 'Advanced',
|
||||
'keychain.export.note.supportsOnly': 'Key export currently supports only',
|
||||
'keychain.export.note.systems': 'systems.',
|
||||
'keychain.export.note.use': 'Use',
|
||||
'keychain.export.note.customize': 'section to customize the export script.',
|
||||
'keychain.export.scriptRequired': 'Script *',
|
||||
'keychain.export.exportToHost': 'Export to host',
|
||||
'keychain.export.failedGeneric': 'Export failed: {message}',
|
||||
'keychain.field.label': 'Label',
|
||||
'keychain.field.labelRequired': 'Label *',
|
||||
'keychain.field.labelPlaceholder': 'Key label',
|
||||
'keychain.field.privateKeyRequired': 'Private key *',
|
||||
'keychain.field.publicKey': 'Public key',
|
||||
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
|
||||
'keychain.generate.keyType': 'Key type',
|
||||
'keychain.generate.keySize': 'Key size',
|
||||
'keychain.generate.labelPlaceholder': 'Key label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
|
||||
'keychain.generate.savePassphrase': 'Save passphrase',
|
||||
'keychain.generate.generate': 'Generate',
|
||||
'keychain.generate.generateSave': 'Generate & Save',
|
||||
'keychain.import.dropHint': 'Drop a key file here',
|
||||
'keychain.import.importFromFile': 'Import from file',
|
||||
'keychain.import.saveKey': 'Save Key',
|
||||
'keychain.import.importedKeyLabel': 'Imported Key',
|
||||
'keychain.identity.usernameRequired': 'Username *',
|
||||
'keychain.identity.method.passwordOnly': 'Password',
|
||||
'keychain.identity.summary.password': 'Auth password',
|
||||
'keychain.identity.summary.key': 'Auth key',
|
||||
'keychain.identity.summary.certificate': 'Auth certificate',
|
||||
'keychain.identity.summary.passwordAndKey': 'Auth password and key',
|
||||
'keychain.identity.summary.passwordAndCertificate': 'Auth password and certificate',
|
||||
'keychain.identity.summary.none': 'No credentials',
|
||||
'keychain.identity.selectCredential': 'Select {kind}',
|
||||
'keychain.identity.save': 'Save',
|
||||
'keychain.identity.update': 'Update',
|
||||
'keychain.keyDialog.newTitle': 'New Key',
|
||||
'keychain.keyDialog.newDesc': 'Add a new SSH key',
|
||||
'keychain.keyDialog.editTitle': 'Edit Key',
|
||||
'keychain.keyDialog.editDesc': 'Update this SSH key',
|
||||
'keychain.keyDialog.updateKey': 'Update Key',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Close session',
|
||||
'tabs.closeLogViewAria': 'Close log view',
|
||||
'tabs.closePluginViewAria': 'Close {title}',
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.duplicateSession': 'Duplicate Session',
|
||||
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
|
||||
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
'keychain.edit.publicKey': 'Public key',
|
||||
'keychain.edit.certificate': 'Certificate',
|
||||
'keychain.edit.certificatePlaceholder': 'Certificate content (optional)',
|
||||
'keychain.edit.filePath': 'File path',
|
||||
'keychain.edit.keyExport': 'Key export',
|
||||
'keychain.edit.exportToHost': 'Export to host',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Search scripts...',
|
||||
'snippets.action.newSnippet': 'New Snippet',
|
||||
'snippets.action.newPackage': 'New Script Package',
|
||||
'snippets.action.import': 'Import',
|
||||
'snippets.action.selectSnippets': 'Select snippets',
|
||||
'snippets.panel.newTitle': 'New Snippet',
|
||||
'snippets.panel.editTitle': 'Edit Snippet',
|
||||
'snippets.panel.newAutomationTitle': 'New Automation Script',
|
||||
'snippets.panel.editAutomationTitle': 'Edit Automation Script',
|
||||
'snippets.panel.resizeWidth': 'Resize panel width',
|
||||
'snippets.field.description': 'Action description',
|
||||
'snippets.field.descriptionPlaceholder': 'Example: check network load',
|
||||
'snippets.field.package': 'Add a script package',
|
||||
'snippets.field.packagePlaceholder': 'Select or create script package',
|
||||
'snippets.field.createPackage': 'Create Script Package',
|
||||
'snippets.field.scriptRequired': 'Script *',
|
||||
'snippets.scriptEditor.expand': 'Open in dialog',
|
||||
'snippets.scriptEditor.resize': 'Resize editor height',
|
||||
'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.',
|
||||
'snippets.history.title': 'Shell History',
|
||||
'snippets.history.subtitle': '{count} commands',
|
||||
'snippets.history.emptyTitle': 'No shell history yet',
|
||||
'snippets.history.emptyDesc': 'Commands you execute will appear here',
|
||||
'snippets.history.loadMore': 'Load more',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': 'Set a label for this snippet',
|
||||
'snippets.history.saveAsSnippet': 'Save as Snippet',
|
||||
'snippets.history.time.justNow': 'just now',
|
||||
'snippets.history.time.minutesAgo': '{count}m ago',
|
||||
'snippets.history.time.hoursAgo': '{count}h ago',
|
||||
'snippets.history.time.daysAgo': '{count}d ago',
|
||||
'snippets.breadcrumb.allPackages': 'All script packages',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Create scripts',
|
||||
'snippets.empty.desc': 'Save common commands as code snippets, or write automation scripts for repeatable operations.',
|
||||
'snippets.search.noResults.title': 'No matches',
|
||||
'snippets.search.noResults.desc': 'No scripts or script packages match "{query}". Try a different search term or clear the search to browse.',
|
||||
'snippets.section.packages': 'Script Packages',
|
||||
'snippets.section.snippets': 'Scripts',
|
||||
'snippets.kind.codeSnippet': 'Code snippet',
|
||||
'snippets.kind.automationScript': 'Automation script',
|
||||
'snippets.package.count': '{count} script(s)',
|
||||
'snippets.commandFallback': 'Command',
|
||||
'snippets.view.grid': 'Grid',
|
||||
'snippets.view.list': 'List',
|
||||
'snippets.selection.selected': '{count} selected',
|
||||
'snippets.selection.selectVisible': 'Select visible',
|
||||
'snippets.selection.deselectAll': 'Deselect all',
|
||||
'snippets.selection.exportSelected': 'Export selected ({count})',
|
||||
'snippets.selection.deleteSelected': 'Delete ({count})',
|
||||
'snippets.selection.deleteConfirmTitle': 'Delete selected items ({count})?',
|
||||
'snippets.selection.deleteConfirmDesc': 'The selected items will be permanently deleted. This action cannot be undone.',
|
||||
'snippets.selection.deleteSuccess': 'Deleted selected items: {count}.',
|
||||
'snippets.export.snippet': 'Export snippet',
|
||||
'snippets.export.package': 'Export script package',
|
||||
'snippets.export.toast.empty': 'No snippets to export.',
|
||||
'snippets.export.toast.successTitle': 'Export ready',
|
||||
'snippets.export.toast.success': 'Exported {count} snippet(s).',
|
||||
'snippets.import.toast.empty': 'No importable snippets found.',
|
||||
'snippets.import.toast.failedTitle': 'Import failed',
|
||||
'snippets.import.toast.invalidDesc': 'This is not a valid NetMesh snippets file.',
|
||||
'snippets.import.toast.successTitle': 'Import completed',
|
||||
'snippets.import.toast.summary': 'Imported {imported}, overwritten {overwritten}, skipped {skipped}.',
|
||||
'snippets.import.modal.title': 'Import snippets',
|
||||
'snippets.import.modal.desc': 'Choose one or more NetMesh snippets JSON files. NetMesh will preview them before anything is imported.',
|
||||
'snippets.import.modal.exampleTitle': 'Example JSON',
|
||||
'snippets.import.modal.noFile': 'No file selected yet. Use the example format below, or a plain JSON array of snippets, then choose one or more files.',
|
||||
'snippets.import.modal.chooseFile': 'Choose file',
|
||||
'snippets.import.modal.downloadExamples': 'Download samples',
|
||||
'snippets.import.modal.multipleFiles': '{count} files selected',
|
||||
'snippets.import.modal.parsedSummary': '{files} file(s), {total} script(s), {packages} script package(s), {conflicts} duplicate command(s). Host bindings are ignored.',
|
||||
'snippets.import.modal.confirm': 'Confirm import',
|
||||
'snippets.import.conflict.title': 'Import snippets?',
|
||||
'snippets.import.conflict.desc': '{file} contains {total} snippet(s). {conflicts} duplicate command(s) already exist.',
|
||||
'snippets.import.conflict.hostBindingsNote': 'Host bindings are not imported or exported with snippets.',
|
||||
'snippets.import.conflict.skip': 'Skip duplicates',
|
||||
'snippets.import.conflict.overwrite': 'Overwrite duplicates',
|
||||
'snippets.packageDialog.title': 'New Script Package',
|
||||
'snippets.packageDialog.parent': 'Parent: {parent}',
|
||||
'snippets.packageDialog.root': 'Root',
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested script packages.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Rename Script Package',
|
||||
'snippets.renameDialog.currentPath': 'Current path: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Enter new name',
|
||||
'snippets.renameDialog.error.empty': 'Script package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A script package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Script package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
|
||||
'snippets.field.multiLineRunMode': 'Multi-line run',
|
||||
'snippets.field.multiLineRunMode.paste': 'Send all at once',
|
||||
'snippets.field.multiLineRunMode.lineDelay': 'Send line by line',
|
||||
'snippets.field.multiLineRunModeHint': 'Use line by line for prompt-based logins or device macros.',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
'snippets.shortkey.recording': 'Press a key combination...',
|
||||
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
|
||||
'snippets.shortkey.clear': 'Clear shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with {name}',
|
||||
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
|
||||
|
||||
'snippets.variables.dialogTitle': 'Snippet variables',
|
||||
'snippets.variables.dialogDesc': 'Fill in values for "{label}" before running.',
|
||||
'snippets.variables.hint': 'Values are inserted as-is into the script (not shell-escaped).',
|
||||
'snippets.variables.preview': 'Preview',
|
||||
'snippets.variables.placeholder': 'Enter a value',
|
||||
'snippets.variables.placeholderDefault': 'Default: {value}',
|
||||
'snippets.variables.required': 'This variable is required',
|
||||
'snippets.variables.run': 'Run',
|
||||
'snippets.field.variablesHelp': 'Use {{name}} or {{name:default}} for placeholders in the script.',
|
||||
'snippets.field.variablesDetected': 'Variables',
|
||||
'snippets.field.variableDefault': 'default {value}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
'serial.modal.desc': 'Configure serial port connection settings',
|
||||
'serial.field.port': 'Serial Port',
|
||||
'serial.field.selectPort': 'Select a port...',
|
||||
'serial.field.baudRate': 'Baud Rate',
|
||||
'serial.field.dataBits': 'Data Bits',
|
||||
'serial.field.stopBits': 'Stop Bits',
|
||||
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
|
||||
'serial.field.parity': 'Parity',
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
'serial.type.custom': 'Custom',
|
||||
'serial.parity.none': 'None',
|
||||
'serial.parity.even': 'Even',
|
||||
'serial.parity.odd': 'Odd',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': 'None',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
|
||||
'serial.field.localEcho': 'Force Local Echo',
|
||||
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.backspaceBehavior': 'Backspace key',
|
||||
'serial.field.backspaceBehaviorDesc': 'Use Ctrl+H for network devices that do not respond to the default Backspace code.',
|
||||
'serial.backspace.default': 'Default (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Charset',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
'serial.field.customBaudRate': 'Using custom baud rate',
|
||||
'serial.field.saveConfig': 'Save Configuration',
|
||||
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
|
||||
'serial.field.configLabel': 'Configuration Name',
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Authentication Required',
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
'passphrase.desc': 'Enter the passphrase for {keyName}',
|
||||
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
|
||||
'passphrase.label': 'Passphrase',
|
||||
'passphrase.keyPath': 'Key',
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
'passphrase.remember': 'Remember this passphrase',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
'sftp.editor.maximize': 'Maximize',
|
||||
'sftp.editor.unsavedTitle': 'Unsaved changes',
|
||||
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
|
||||
'sftp.editor.discardChanges': 'Discard',
|
||||
'sftp.editor.saveAndClose': 'Save and close',
|
||||
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
|
||||
|
||||
};
|
||||
1085
application/i18n/locales/en/vault.ts
Normal file
1085
application/i18n/locales/en/vault.ts
Normal file
File diff suppressed because it is too large
Load Diff
20
application/i18n/locales/es.ts
Normal file
20
application/i18n/locales/es.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Messages } from './types';
|
||||
import { esCoreMessages } from './es/core';
|
||||
import { esVaultMessages } from './es/vault';
|
||||
import { esTerminalMessages } from './es/terminal';
|
||||
import { esAiMessages } from './es/ai';
|
||||
import { esSystemManagerMessages } from './es/systemManager';
|
||||
import { esScriptsMessages } from './es/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
const es: Messages = {
|
||||
...esCoreMessages,
|
||||
...esVaultMessages,
|
||||
...esTerminalMessages,
|
||||
...esAiMessages,
|
||||
...esSystemManagerMessages,
|
||||
...esScriptsMessages,
|
||||
};
|
||||
|
||||
export default es;
|
||||
672
application/i18n/locales/es/ai.ts
Normal file
672
application/i18n/locales/es/ai.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
|
||||
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Actividad del agente',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
'ai.chat.activity.webSearch': 'Búsqueda web',
|
||||
'ai.chat.activity.fileChanges': 'Cambios de archivos',
|
||||
'ai.chat.activity.status.running': 'En ejecución',
|
||||
'ai.chat.activity.status.completed': 'Completado',
|
||||
'ai.chat.activity.status.failed': 'Falló',
|
||||
'ai.chat.activity.file.add': 'Agregar',
|
||||
'ai.chat.activity.file.update': 'Actualizar',
|
||||
'ai.chat.activity.file.delete': 'Eliminar',
|
||||
'ai.chat.activity.usage': 'Tokens',
|
||||
'ai.chat.activity.usage.input': 'entrada',
|
||||
'ai.chat.activity.usage.output': 'salida',
|
||||
'ai.chat.activity.usage.cached': 'en caché',
|
||||
'ai.chat.activity.usage.reasoning': 'razonamiento',
|
||||
'ai.title': 'IA',
|
||||
'ai.description': 'Configura proveedores de IA, agentes y ajustes de seguridad',
|
||||
'ai.providers': 'Proveedores',
|
||||
'ai.agents': 'Agentes',
|
||||
'ai.providers.empty': 'No hay proveedores configurados. Agrega un proveedor para comenzar.',
|
||||
'ai.providers.add': 'Agregar proveedor',
|
||||
'ai.providers.active': 'Activo',
|
||||
'ai.providers.apiKeyConfigured': 'Clave de API configurada',
|
||||
'ai.providers.noApiKey': 'Sin clave de API',
|
||||
'ai.providers.configure': 'Configurar',
|
||||
'ai.providers.remove': 'Quitar',
|
||||
'ai.providers.name': 'Nombre para mostrar',
|
||||
'ai.providers.name.placeholder': 'p. ej. Mi proveedor',
|
||||
'ai.providers.style': 'Estilo de protocolo',
|
||||
'ai.providers.style.anthropic': 'Compatible con Anthropic',
|
||||
'ai.providers.style.openai': 'Compatible con OpenAI',
|
||||
'ai.providers.style.google': 'Compatible con Google',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selecciona qué formato de API usan las peticiones. Anúlalo cuando un endpoint de terceros hable un dialecto distinto al que sugiere su tipo de proveedor.',
|
||||
'ai.providers.openaiApi': 'Formato de API de OpenAI',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': 'Chat Completions funciona con la mayoría de endpoints compatibles con OpenAI. Responses puede mejorar la tasa de aciertos de caché en relés que admiten /v1/responses.',
|
||||
'ai.providers.icon.change': 'Cambiar ícono',
|
||||
'ai.providers.icon.upload': 'Subir imagen',
|
||||
'ai.providers.icon.reset': 'Restablecer',
|
||||
'ai.providers.icon.close': 'Cerrar',
|
||||
'ai.providers.icon.uploadedNote': 'Ícono personalizado (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Elige un archivo de imagen.',
|
||||
'ai.providers.apiKey': 'Clave de API',
|
||||
'ai.providers.apiKey.placeholder': 'Ingresa la clave de API',
|
||||
'ai.providers.apiKey.decrypting': 'Descifrando...',
|
||||
'ai.providers.baseUrl': 'URL base',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Compatible con Anthropic: host con o sin /v1 (por ejemplo https://gateway.example o https://gateway.example/v1). La detección y el chat usan /v1/models y /v1/messages.',
|
||||
'ai.providers.baseUrl.ollamaHelp': 'Ollama local: http://localhost:11434/v1 (sin API key). Ollama Cloud: https://ollama.com/v1 y tu clave de Cloud.',
|
||||
'ai.providers.skipTLSVerify': 'Omitir la verificación del certificado TLS (para certificados autofirmados)',
|
||||
'ai.providers.defaultModel': 'Modelo predeterminado',
|
||||
'ai.providers.defaultModel.placeholder': 'p. ej. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Ventana de contexto',
|
||||
'ai.providers.contextWindow.placeholder': 'p. ej. 128000',
|
||||
'ai.providers.contextWindow.help': 'Déjalo en blanco para usar el valor de la lista de modelos cuando esté disponible; de lo contrario, NetMesh usa un valor predeterminado seguro.',
|
||||
'ai.providers.contextWindow.error': 'Ingresa un número entero positivo o déjalo en blanco.',
|
||||
'ai.providers.refreshModels': 'Actualizar modelos',
|
||||
'ai.providers.test': 'Probar',
|
||||
'ai.providers.test.testing': 'Probando…',
|
||||
'ai.providers.test.ok': 'Conectado ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Se alcanzó el endpoint, pero la respuesta parece incompleta ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Conectado, pero lento ({latency} ms)',
|
||||
'ai.providers.test.error': 'Falló ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Primero ingresa una URL base',
|
||||
'ai.providers.test.missingApiKey': 'Primero ingresa una clave de API',
|
||||
'ai.providers.test.unavailable': 'La prueba de conexión no está disponible en este entorno',
|
||||
'ai.providers.searchModel': 'Busca o escribe el ID del modelo...',
|
||||
'ai.providers.filterModels': 'Filtrar modelos...',
|
||||
'ai.providers.loadingModels': 'Cargando modelos...',
|
||||
'ai.providers.noMatchingModels': 'No hay modelos que coincidan',
|
||||
'ai.providers.clickToLoadModels': 'Haz clic para cargar los modelos',
|
||||
'ai.providers.showingModels': 'Mostrando los primeros 100 de {count} modelos. Escribe para filtrar.',
|
||||
'ai.providers.advancedParams': 'Parámetros avanzados',
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Conecta OpenAI Codex. Inicia sesión con ChatGPT aquí, o habilita una clave de API de un proveedor compatible con OpenAI y un endpoint personalizado en Configuración.',
|
||||
'ai.codex.appServer.title': 'Usar Codex App Server',
|
||||
'ai.codex.appServer.experimental': 'Experimental',
|
||||
'ai.codex.appServer.description': 'Usa el protocolo persistente de Codex para aprobaciones nativas, controles de sandbox, modelos en vivo y preguntas a mitad de turno. El SDK sigue siendo el predeterminado.',
|
||||
'ai.codex.appServer.checking': 'Comprobando la compatibilidad con App Server…',
|
||||
'ai.codex.appServer.available': 'App Server está disponible para esta Codex CLI.',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'El catálogo de modelos de Codex en vivo no está disponible. Se usa la lista de modelos integrada.',
|
||||
'ai.codex.appServer.approval.allowSession': 'Permitir por sesión',
|
||||
'ai.codex.appServer.userInput.title': 'Codex necesita tu aporte',
|
||||
'ai.codex.appServer.userInput.description': 'Responde estas preguntas para continuar el turno actual.',
|
||||
'ai.codex.appServer.userInput.other': 'Ingresa otra respuesta',
|
||||
'ai.codex.appServer.userInput.autoResolve': 'Codex continuará automáticamente si no se proporciona una respuesta a tiempo.',
|
||||
'ai.codex.appServer.userInput.skip': 'Omitir',
|
||||
'ai.codex.appServer.userInput.submit': 'Continuar',
|
||||
'ai.codex.steer.addInstruction': 'Agregar instrucción',
|
||||
'ai.codex.steer.sending': 'Agregando instrucción…',
|
||||
'ai.codex.steer.placeholder': 'Agrega una instrucción mientras Codex está trabajando…',
|
||||
'ai.codex.steer.notSteerableReview': 'Este turno de revisión de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
|
||||
'ai.codex.steer.notSteerableCompact': 'Este turno de compactación de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
|
||||
'ai.codex.steer.busy': 'Ya se está enviando otra instrucción a Codex.',
|
||||
'ai.codex.steer.inactive': 'El turno de Codex ya terminó. Tu borrador se conservó.',
|
||||
'ai.codex.steer.unsupported': 'Las instrucciones en curso requieren el runtime de Codex App Server.',
|
||||
'ai.codex.steer.failed': 'Codex no pudo aceptar la instrucción adicional. Tu borrador se conservó.',
|
||||
'ai.codex.detecting': 'Detectando...',
|
||||
'ai.codex.notFound': 'No encontrado',
|
||||
'ai.codex.awaitingLogin': 'Esperando inicio de sesión',
|
||||
'ai.codex.connectedChatGPT': 'Conectado mediante ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Conectado mediante clave de API',
|
||||
'ai.codex.connectedCustomConfig': 'Conectado mediante ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Configuración personalizada detectada (falta la variable de entorno)',
|
||||
'ai.codex.customConfigHint': 'Usando el proveedor personalizado "{provider}" configurado en ~/.codex/config.toml: no se necesita inicio de sesión con ChatGPT.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Advertencia: {envKey} no está definida en tu entorno de shell. Expórtala (o inicia NetMesh desde un shell que la tenga) para que Codex pueda autenticarse.',
|
||||
'ai.codex.notConnected': 'No conectado',
|
||||
'ai.codex.statusUnknown': 'Estado desconocido',
|
||||
'ai.codex.path': 'Ruta:',
|
||||
'ai.codex.notFoundHint': 'No se pudo encontrar codex en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.codex.customPathPlaceholder': 'p. ej. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Verificar',
|
||||
'ai.codex.resetPath': 'Restablecer',
|
||||
'ai.codex.openLogin': 'Abrir inicio de sesión',
|
||||
'ai.codex.logout': 'Cerrar sesión',
|
||||
'ai.codex.connectChatGPT': 'Conectar ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Actualizar estado',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "El asistente de codificación agéntica de Anthropic. Requiere la CLI de Claude Code del sistema.",
|
||||
'ai.claude.detecting': 'Detectando...',
|
||||
'ai.claude.detected': 'Detectado',
|
||||
'ai.claude.notFound': 'No encontrado',
|
||||
'ai.claude.path': 'Ruta:',
|
||||
'ai.claude.notFoundHint': 'No se pudo encontrar claude en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.claude.customPathPlaceholder': 'p. ej. /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Autenticación y configuración (opcional)',
|
||||
'ai.claude.configDir': 'Directorio de configuración',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (déjalo en blanco para usar el predeterminado)',
|
||||
'ai.claude.configDir.hint': 'Define CLAUDE_CONFIG_DIR: apunta a una carpeta donde hayas ejecutado el inicio de sesión `claude` (contiene settings.json + credenciales).',
|
||||
'ai.claude.settings': 'Archivo de configuración',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (ruta o JSON en línea {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Opcional. Una ruta a settings.json o un JSON en línea, pasado al SDK como `settings`. Se suma a — y es independiente de — el directorio de configuración anterior (se combina por encima, no lo reemplaza).',
|
||||
'ai.claude.envVars': 'Variables de entorno',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de Claude. Se almacena localmente en texto plano; para claves de API o credenciales, prefiere el directorio de configuración anterior (un inicio de sesión `claude`).',
|
||||
'ai.claude.check': 'Verificar',
|
||||
'ai.claude.resetPath': 'Restablecer',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Usa la CLI de GitHub Copilot. Una vez detectada, se puede seleccionar como agente de codificación externo.',
|
||||
'ai.copilot.detecting': 'Detectando...',
|
||||
'ai.copilot.detected': 'Detectado',
|
||||
'ai.copilot.notFound': 'No encontrado',
|
||||
'ai.copilot.path': 'Ruta:',
|
||||
'ai.copilot.notFoundHint': 'No se pudo encontrar copilot en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.copilot.customPathPlaceholder': 'p. ej. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Verificar',
|
||||
'ai.copilot.resetPath': 'Restablecer',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Usa el SDK de Cursor o el inicio de sesión local de la CLI de Agent.',
|
||||
'ai.cursor.detecting': 'Detectando...',
|
||||
'ai.cursor.detected': 'Disponible',
|
||||
'ai.cursor.notFound': 'No disponible',
|
||||
'ai.cursor.path': 'Runtime:',
|
||||
'ai.cursor.notFoundHint': 'Ingresa una clave de API para habilitar Cursor o cambia al inicio de sesión por CLI.',
|
||||
'ai.cursor.notInstalledHint': 'No se detectó el SDK de Cursor ni la CLI de Agent.',
|
||||
'ai.cursor.installStatus': 'Runtime de Cursor',
|
||||
'ai.cursor.installed': 'Detectado',
|
||||
'ai.cursor.notInstalled': 'No detectado',
|
||||
'ai.cursor.modeCli': 'Inicio de sesión por CLI',
|
||||
'ai.cursor.modeApiKey': 'Clave de API',
|
||||
'ai.cursor.modeCliHint': 'Usa tu sesión local de `cursor-agent login` y la cuota Automática de la suscripción. La clave de API guardada se conserva, pero no se usa en este modo.',
|
||||
'ai.cursor.modeApiKeyHint': 'Usa la API de Cursor medida. El inicio de sesión por CLI se ignora mientras este modo esté activo.',
|
||||
'ai.cursor.cliLoginStatus': 'Inicio de sesión por CLI',
|
||||
'ai.cursor.cliLoginOk': 'Sesión iniciada',
|
||||
'ai.cursor.cliLoginAs': 'Sesión iniciada como {{email}}',
|
||||
'ai.cursor.cliLoginMissing': 'Sin sesión iniciada',
|
||||
'ai.cursor.cliLoginHint': 'Ejecuta `cursor-agent login` en una terminal y luego haz clic en Verificar.',
|
||||
'ai.cursor.apiKeyStatus': 'Clave de API',
|
||||
'ai.cursor.apiKeyConfigured': 'Configurada',
|
||||
'ai.cursor.apiKeyMissing': 'Faltante',
|
||||
'ai.cursor.apiKeyFromEnv': 'Desde el entorno',
|
||||
'ai.cursor.apiKey': 'Clave de API',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Ingresa la clave de API de Cursor',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Usando CURSOR_API_KEY; ingresa una clave para anularla',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor puede usar CURSOR_API_KEY de tu shell. Guarda una clave aquí solo si quieres que NetMesh la anule.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'NetMesh usará la clave guardada aquí antes que CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Guardar',
|
||||
'ai.cursor.saved': 'Guardada',
|
||||
'ai.cursor.showApiKey': 'Mostrar clave de API',
|
||||
'ai.cursor.hideApiKey': 'Ocultar clave de API',
|
||||
'ai.cursor.customPathPlaceholder': 'p. ej. /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Verificar',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Usa CodeBuddy Code mediante el SDK oficial de Agent (`@tencent-ai/agent-sdk`). Una vez detectado, se puede seleccionar como agente de codificación externo.',
|
||||
'ai.codebuddy.detecting': 'Detectando...',
|
||||
'ai.codebuddy.detected': 'Detectado',
|
||||
'ai.codebuddy.notFound': 'No encontrado',
|
||||
'ai.codebuddy.path': 'Ruta:',
|
||||
'ai.codebuddy.notFoundHint': 'No se pudo encontrar codebuddy en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'p. ej. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Verificar',
|
||||
'ai.codebuddy.resetPath': 'Restablecer',
|
||||
'ai.codebuddy.configSection': 'Autenticación y configuración (opcional)',
|
||||
'ai.codebuddy.internetEnv': 'Entorno de Internet',
|
||||
'ai.codebuddy.internetEnv.default': 'Predeterminado (internacional)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Interno',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Define CODEBUDDY_INTERNET_ENVIRONMENT: elige Interno o IOA para entornos de red restringidos.',
|
||||
'ai.codebuddy.envVars': 'Variables de entorno',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
|
||||
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
|
||||
'ai.codebuddy.effort.default': 'Predeterminado',
|
||||
'ai.codebuddy.effort.low': 'Bajo',
|
||||
'ai.codebuddy.effort.medium': 'Medio',
|
||||
'ai.codebuddy.effort.high': 'Alto',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
|
||||
'ai.codebuddy.maxTurns': 'Máximo de turnos',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
|
||||
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
|
||||
'ai.codebuddy.sandbox': 'Modo sandbox',
|
||||
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
|
||||
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
|
||||
'ai.codebuddy.elicitation.description': 'Revisa la petición para continuar el turno actual.',
|
||||
'ai.codebuddy.elicitation.select': 'Selecciona una opción',
|
||||
'ai.codebuddy.elicitation.yes': 'Sí',
|
||||
'ai.codebuddy.elicitation.no': 'No',
|
||||
'ai.codebuddy.elicitation.decline': 'Rechazar',
|
||||
'ai.codebuddy.elicitation.accept': 'Continuar',
|
||||
'ai.codebuddy.elicitation.validation.required': '{field} es obligatorio.',
|
||||
'ai.codebuddy.elicitation.validation.invalidType': '{field} tiene un valor no válido.',
|
||||
'ai.codebuddy.elicitation.validation.integer': '{field} debe ser un número entero.',
|
||||
'ai.codebuddy.elicitation.validation.notInteger': '{field} debe ser un número entero.',
|
||||
'ai.codebuddy.elicitation.validation.minimum': '{field} debe ser al menos {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.maximum': '{field} debe ser como máximo {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.minLength': '{field} debe contener al menos {limit} caracteres.',
|
||||
'ai.codebuddy.elicitation.validation.maxLength': '{field} debe contener como máximo {limit} caracteres.',
|
||||
'ai.codebuddy.elicitation.validation.minItems': 'Selecciona al menos {limit} opciones para {field}.',
|
||||
'ai.codebuddy.elicitation.validation.maxItems': 'Selecciona como máximo {limit} opciones para {field}.',
|
||||
'ai.codebuddy.elicitation.validation.format': '{field} debe coincidir con el formato {format}.',
|
||||
'ai.codebuddy.elicitation.validation.option': 'Selecciona una opción válida para {field}.',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': 'Usa OpenCode mediante el SDK oficial. Configura proveedores y claves en OpenCode y luego selecciónalo como agente de codificación externo.',
|
||||
'ai.opencode.detecting': 'Detectando...',
|
||||
'ai.opencode.detected': 'Detectado',
|
||||
'ai.opencode.notFound': 'No encontrado',
|
||||
'ai.opencode.path': 'Ruta:',
|
||||
'ai.opencode.notFoundHint': 'No se pudo encontrar opencode en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
|
||||
'ai.grok.detecting': 'Detectando...',
|
||||
'ai.grok.detected': 'Detectado',
|
||||
'ai.grok.notFound': 'No encontrado',
|
||||
'ai.grok.path': 'Ruta:',
|
||||
'ai.grok.notFoundHint': 'No se pudo encontrar grok en el PATH. Instala la CLI de Grok Build o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.grok.customPathPlaceholder': 'p. ej. /usr/local/bin/grok',
|
||||
'ai.grok.check': 'Verificar',
|
||||
'ai.grok.resetPath': 'Restablecer',
|
||||
'ai.grok.runtime.acp.title': 'Usar Grok ACP (agente stdio)',
|
||||
'ai.grok.runtime.acp.default': 'Predeterminado',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'Habla con Grok mediante el Protocolo de Cliente de Agente (grok agent stdio). Inyecta el MCP de NetMesh en session/new. Apágalo para usar la ruta original de CLI headless con streaming-json.',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'Usando streaming-json headless (grok -p --output-format streaming-json). El archivo .grok/config.toml del proyecto se usa para la inyección de MCP.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Agente predeterminado',
|
||||
'ai.defaultAgent.description': 'Agente que se usará al iniciar una nueva sesión de IA',
|
||||
'ai.defaultAgent.catty': 'Catty (Integrado)',
|
||||
'ai.toolAccess.title': 'Acceso a herramientas',
|
||||
'ai.toolAccess.mode': 'Modo de acceso de NetMesh',
|
||||
'ai.toolAccess.description': 'Elige cómo acceden los agentes externos a las sesiones de NetMesh. MCP expone el servidor integrado, mientras que Habilidades + CLI apunta a los agentes hacia las habilidades locales de NetMesh y los comandos de CLI.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Habilidades + CLI',
|
||||
'ai.toolAccess.mcpPrompt.title': 'Prompt para tu cliente de IA',
|
||||
'ai.toolAccess.mcpPrompt.description': 'Pega este prompt en tu cliente de IA (Codex, Claude Code, …) y registrará el MCP de NetMesh por ti.',
|
||||
'ai.toolAccess.mcpPrompt.enableHint': 'Activa MCP externo abajo para incluir la ruta del launcher en este prompt.',
|
||||
'ai.toolAccess.skills.file': 'Archivo de skill',
|
||||
'ai.toolAccess.skills.description': 'En modo Habilidades + CLI, los agentes apuntan automáticamente a este archivo de skill local. La ruta del lanzador de la CLI de NetMesh se entrega al agente en cada sesión.',
|
||||
'ai.toolAccess.skills.unavailable': 'Ruta del archivo de skill no disponible',
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'MCP externo',
|
||||
'ai.externalMcp.description': 'Expón NetMesh como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén NetMesh en ejecución mientras los clientes estén conectados.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
|
||||
'ai.externalMcp.mode': 'Modo de disponibilidad',
|
||||
'ai.externalMcp.mode.temporary': 'Temporal',
|
||||
'ai.externalMcp.mode.persistent': 'Siempre activo',
|
||||
'ai.externalMcp.mode.description': 'El modo temporal se desactiva automáticamente tras el tiempo de inactividad. El modo Siempre activo restaura el MCP externo cuando se inicia NetMesh.',
|
||||
'ai.externalMcp.idleTimeout': 'Tiempo de inactividad',
|
||||
'ai.externalMcp.idleTimeout.description': 'En modo temporal, desactiva el MCP externo después de esta cantidad de minutos sin operaciones de MCP.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
|
||||
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
|
||||
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'Cierra automáticamente las sesiones abiertas por una IA después de esta cantidad de minutos sin actividad de terminal o archivos.',
|
||||
'ai.externalMcp.usage.title': 'Cómo usarlo',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Activa el MCP externo y mantén NetMesh en ejecución.',
|
||||
'ai.externalMcp.usage.localhost': '2. Los clientes se conectan mediante el launcher local (solo 127.0.0.1). El descubrimiento se elimina cuando desactivas el interruptor.',
|
||||
'ai.externalMcp.usage.permissions': '3. Las operaciones de escritura siguen Configuración → IA → Seguridad (observador / confirmar / automático) y la lista de bloqueo de comandos.',
|
||||
'ai.externalMcp.usage.capabilities': '4. Todas las herramientas del catálogo están disponibles: terminal, SFTP, Vault y reenvío de puertos. Los secretos (contraseñas / claves privadas) nunca se devuelven.',
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
|
||||
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de NetMesh sin solicitar aprobación de NetMesh; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
|
||||
'ai.externalMcp.permissionMode.unknown': 'Desconocido',
|
||||
'ai.externalMcp.discovery': 'Descubrimiento',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': 'No disponible',
|
||||
'ai.externalMcp.bridgeUnavailable': 'Puente de MCP externo no disponible',
|
||||
'ai.externalMcp.copy': 'Copiar',
|
||||
'ai.externalMcp.copied': 'Copiado',
|
||||
'ai.externalMcp.copyFailed': 'No se pudo copiar. Intenta copiarlo manualmente.',
|
||||
'ai.externalMcp.refresh': 'Actualizar',
|
||||
'ai.externalMcp.clientConfiguration': 'Configuración del cliente',
|
||||
'ai.externalMcp.clientConfiguration.description': 'Elige un cliente para instalarlo con un clic, o copia fragmentos de CLI o configuración.',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'Comando CLI',
|
||||
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
|
||||
'ai.externalMcp.addToCodex': 'Agregar a Codex',
|
||||
'ai.externalMcp.addToClaude': 'Agregar a Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Agregar a Grok',
|
||||
'ai.externalMcp.codexAdded': 'Entrada de MCP de Codex agregada. Reinicia Codex o abre una nueva sesión de Codex.',
|
||||
'ai.externalMcp.claudeAdded': 'Entrada de MCP de Claude Code agregada. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
|
||||
'ai.externalMcp.grokAdded': 'Entrada de MCP de Grok agregada. Reinicia Grok o abre una nueva sesión de Grok.',
|
||||
'ai.externalMcp.installCodex': 'Instala Codex por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installClaude': 'Instala Claude Code por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installGrok': 'Instala la CLI de Grok por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.conflict.description': 'Ya existe una entrada NetMesh-external que apunta a otro lugar. Quítala o edítala manualmente.',
|
||||
'ai.externalMcp.enableForLauncher': 'Habilita el MCP externo para obtener una ruta de launcher utilizable.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / otros clientes',
|
||||
'ai.externalMcp.cursor.description': 'Combina esto en tu configuración de MCP (por ejemplo, ~/.cursor/mcp.json). No reemplaces todo el archivo si ya tienes otros servidores.',
|
||||
'ai.externalMcp.status.unavailable': 'No disponible',
|
||||
'ai.externalMcp.status.disabled': 'Desactivado',
|
||||
'ai.externalMcp.status.running': 'En ejecución',
|
||||
'ai.externalMcp.status.starting': 'Iniciando',
|
||||
'ai.externalMcp.status.error': 'Error',
|
||||
'ai.externalMcp.status.configured': 'Configurado',
|
||||
'ai.externalMcp.status.notConfigured': 'No configurado',
|
||||
'ai.externalMcp.status.checking': 'Comprobando',
|
||||
'ai.externalMcp.status.codexNotFound': 'Codex no encontrado',
|
||||
'ai.externalMcp.status.claudeNotFound': 'Claude Code no encontrado',
|
||||
'ai.externalMcp.status.grokNotFound': 'Grok no encontrado',
|
||||
'ai.externalMcp.status.conflict': 'Conflicto',
|
||||
'ai.userSkills.title': 'Habilidades de usuario',
|
||||
'ai.userSkills.description': 'Abre la carpeta de habilidades de NetMesh para agregar tus propios directorios de habilidades. NetMesh escanea estas habilidades automáticamente e inyecta solo índices ligeros, salvo que una habilidad coincida claramente con la petición actual.',
|
||||
'ai.userSkills.openFolder': 'Abrir carpeta de habilidades',
|
||||
'ai.userSkills.reload': 'Recargar habilidades',
|
||||
'ai.userSkills.location': 'Ubicación',
|
||||
'ai.userSkills.loading': 'Escaneando habilidades de usuario...',
|
||||
'ai.userSkills.summary': '{ready} listas, {warnings} advertencias',
|
||||
'ai.userSkills.empty': 'Aún no se encontraron habilidades de usuario. Abre la carpeta para agregar directorios de habilidades con un archivo SKILL.md.',
|
||||
'ai.userSkills.unavailable': 'Las habilidades de usuario no están disponibles en este entorno.',
|
||||
'ai.userSkills.status.ready': 'Lista',
|
||||
'ai.userSkills.status.warning': 'Advertencia',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Mensajes rápidos',
|
||||
'ai.quickMessages.description': 'Crea prompts reutilizables que puedes insertar desde el chat de IA con / o con el botón de mensajes rápidos. A diferencia de las habilidades de usuario, los mensajes rápidos llenan el compositor con texto.',
|
||||
'ai.quickMessages.add': 'Agregar mensaje rápido',
|
||||
'ai.quickMessages.createTitle': 'Nuevo mensaje rápido',
|
||||
'ai.quickMessages.editTitle': 'Editar mensaje rápido',
|
||||
'ai.quickMessages.name': 'Nombre',
|
||||
'ai.quickMessages.name.placeholder': 'p. ej. Verificar espacio en disco',
|
||||
'ai.quickMessages.slug': 'Comando',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Descripción (opcional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Breve indicación sobre lo que hace este prompt',
|
||||
'ai.quickMessages.content': 'Contenido del mensaje',
|
||||
'ai.quickMessages.content.placeholder': 'Texto completo del prompt para insertar al seleccionarlo...',
|
||||
'ai.quickMessages.empty': 'Aún no hay mensajes rápidos. Agrega algunos prompts que uses seguido.',
|
||||
'ai.quickMessages.confirmDelete': '¿Eliminar el mensaje rápido "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'El nombre es obligatorio.',
|
||||
'ai.quickMessages.error.invalidSlug': 'El comando solo puede contener letras minúsculas, números y guiones.',
|
||||
'ai.quickMessages.error.contentRequired': 'El contenido del mensaje es obligatorio.',
|
||||
'ai.quickMessages.error.slugTaken': 'Este comando ya lo usa otro mensaje rápido.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'Este comando entra en conflicto con la habilidad de usuario "/{slug}". Elige otro.',
|
||||
'ai.quickMessages.error.maxItems': 'Puedes guardar como máximo {max} mensajes rápidos.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No hay ningún proveedor de IA configurado. Ve a **Configuración → IA → Proveedores** para agregar y habilitar un proveedor.',
|
||||
'ai.chat.toolDenied': 'La acción fue rechazada por el usuario.',
|
||||
'ai.chat.toolApproved': 'Aprobado',
|
||||
'ai.chat.toolApprovalHint': 'Presiona Enter una vez · Esc para rechazar',
|
||||
'ai.chat.approve': 'Aprobar',
|
||||
'ai.chat.approveOnce': 'Una vez',
|
||||
'ai.chat.alwaysAllow': 'Siempre',
|
||||
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
|
||||
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
|
||||
'ai.chat.reject': 'Rechazar',
|
||||
'ai.chat.toolLabel': 'Herramienta',
|
||||
'ai.chat.targetLabel': 'Destino',
|
||||
'ai.chat.rawCommand': 'Comando',
|
||||
'ai.chat.copyCommand': 'Copiar',
|
||||
'ai.chat.commandCopied': 'Copiado',
|
||||
'ai.chat.approvalSession': 'Sesión',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Motivo',
|
||||
'ai.chat.approvalInvocation': 'Invocación',
|
||||
'ai.chat.permissionRequired': 'Permiso requerido',
|
||||
'ai.chat.permissionDescription': 'El agente de IA quiere ejecutar una llamada a una herramienta que requiere tu aprobación.',
|
||||
'ai.chat.commandBlocked': 'Este comando está bloqueado por tu política de seguridad y no se puede ejecutar.',
|
||||
'ai.chat.recommendAllow': 'Permitir',
|
||||
'ai.chat.recommendConfirm': 'Confirmar',
|
||||
'ai.chat.recommendDeny': 'Denegar',
|
||||
'ai.chat.exportConversation': 'Exportar conversación',
|
||||
'ai.chat.exportAs': 'Exportar como',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Texto plano',
|
||||
'ai.chat.thinking': 'Pensando',
|
||||
'ai.chat.thoughtFor': 'Pensó durante {duration}',
|
||||
'ai.chat.thought': 'Pensamiento',
|
||||
'ai.chat.agents': 'Agentes',
|
||||
'ai.chat.detectedOnMachine': 'Detectado en esta máquina',
|
||||
'ai.chat.rescan': 'Volver a escanear',
|
||||
'ai.chat.permObserver': 'Observador',
|
||||
'ai.chat.permConfirm': 'Confirmar',
|
||||
'ai.chat.permAuto': 'Automático',
|
||||
'ai.chat.permObserverDesc': 'Solo lectura',
|
||||
'ai.chat.permConfirmDesc': 'Preguntar antes de escribir',
|
||||
'ai.chat.permAutoDesc': 'Ejecutar libremente',
|
||||
'ai.chat.emptyHint': 'Pregunta sobre tus servidores, ejecuta comandos u obtén ayuda con las configuraciones.',
|
||||
'ai.chat.placeholder': 'Mensaje a {agent} — @ para incluir contexto, / para comandos',
|
||||
'ai.chat.placeholderDefault': 'Mensaje al agente Catty...',
|
||||
'ai.chat.noModel': 'Sin modelo',
|
||||
'ai.chat.noProviderModel': 'No hay modelo predeterminado: define uno en Configuración → IA → Proveedores.',
|
||||
'ai.chat.selectProvider': 'Seleccionar proveedor',
|
||||
'ai.chat.selectProviderAndModel': 'Seleccionar proveedor y modelo',
|
||||
'ai.chat.selectModel': 'Seleccionar modelo',
|
||||
'ai.chat.searchModels': 'Buscar modelos',
|
||||
'ai.chat.providers': 'Proveedores',
|
||||
'ai.chat.models': 'Modelos',
|
||||
'ai.chat.pinned': 'Fijados',
|
||||
'ai.chat.useCustomModel': 'Usar "{id}"',
|
||||
'ai.chat.thinkingLevel': 'Razonamiento',
|
||||
'ai.chat.thinkingOff': 'Off',
|
||||
'ai.chat.pinModel': 'Fijar modelo',
|
||||
'ai.chat.unpinModel': 'Quitar modelo fijado',
|
||||
'ai.chat.loadingModels': 'Cargando modelos...',
|
||||
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
'ai.chat.untitled': 'Sin título',
|
||||
'ai.chat.justNow': 'Ahora mismo',
|
||||
'ai.chat.minutesAgo': 'hace {n} min',
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
'ai.chat.daysAgo': 'hace {n} d',
|
||||
'ai.chat.newChat': 'Nuevo chat',
|
||||
'ai.chat.allSessions': 'Todas las sesiones',
|
||||
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
|
||||
'ai.chat.jumpNav': 'Ir al mensaje',
|
||||
'ai.chat.jumpUntitled': '(mensaje vacío)',
|
||||
'ai.chat.usedTools': 'Herramientas usadas: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
'ai.chat.menuContext': 'Contexto',
|
||||
'ai.chat.menuFiles': 'Archivos',
|
||||
'ai.chat.menuImage': 'Imagen',
|
||||
'ai.chat.menuMentionHost': 'Mencionar host',
|
||||
'ai.chat.menuMentionNote': 'Mencionar nota',
|
||||
'ai.chat.mentionNoteSearch': 'Buscar notas…',
|
||||
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
|
||||
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
|
||||
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
|
||||
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
|
||||
'ai.chat.untitledNote': 'Nota sin título',
|
||||
'ai.chat.menuUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.menuSlashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashSystemCommands': 'Comandos',
|
||||
'ai.chat.slashQuickMessages': 'Mensajes rápidos',
|
||||
'ai.chat.slashUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.quickMessages': 'Comandos de barra',
|
||||
'ai.chat.slashNoResults': 'No hay comandos que coincidan',
|
||||
'ai.chat.slashEmptyHint': 'Agrega prompts en Configuración → IA → Mensajes rápidos.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Atajos de chat',
|
||||
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia NetMesh por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Búsqueda web',
|
||||
'ai.webSearch.enable': 'Habilitar búsqueda web',
|
||||
'ai.webSearch.enable.description': 'Permite que el agente de IA busque en la web información actual.',
|
||||
'ai.webSearch.provider': 'Proveedor de búsqueda',
|
||||
'ai.webSearch.provider.description': 'Elige un proveedor de API de búsqueda web.',
|
||||
'ai.webSearch.apiKey': 'Clave de API',
|
||||
'ai.webSearch.apiKey.description': 'Clave de API para el proveedor de búsqueda seleccionado.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Ingresa la clave de API...',
|
||||
'ai.webSearch.apiHost': 'Host de API',
|
||||
'ai.webSearch.apiHost.description': 'Endpoint de API personalizado. Deja el predeterminado salvo que uses un proxy.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL de tu instancia de SearXNG (obligatoria).',
|
||||
'ai.webSearch.maxResults': 'Máximo de resultados',
|
||||
'ai.webSearch.maxResults.description': 'Cantidad máxima de resultados de búsqueda a devolver (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Seguridad',
|
||||
'ai.safety.permissionMode': 'Modo de permisos',
|
||||
'ai.safety.permissionMode.description': 'Controla cómo interactúa la IA con tus sesiones de terminal de NetMesh. El modo Observador bloquea las operaciones de escritura que pasan por NetMesh. Las CLIs de agentes externos pueden seguir teniendo sus propias herramientas locales y flujo de aprobación.',
|
||||
'ai.safety.permissionMode.observer': 'Observador: solo lectura, sin acciones',
|
||||
'ai.safety.permissionMode.confirm': 'Confirmar: preguntar antes de las acciones',
|
||||
'ai.safety.permissionMode.auto': 'Automático: ejecutar libremente',
|
||||
'ai.safety.commandTimeout': 'Tiempo de espera de comandos',
|
||||
'ai.safety.commandTimeout.description': 'Máximo de segundos que un comando puede ejecutarse antes de ser terminado mediante la ejecución de NetMesh.',
|
||||
'ai.safety.commandTimeout.unit': 'seg',
|
||||
'ai.safety.responseIdleTimeout': 'Espera de respuesta de la IA integrada',
|
||||
'ai.safety.responseIdleTimeout.description': 'Cancela una solicitud de la IA integrada tras este número de segundos sin una nueva respuesta. No controla la duración total ni la ejecución de comandos.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'seg',
|
||||
'ai.safety.maxIterations': 'Máximo de iteraciones',
|
||||
'ai.safety.maxIterations.description': 'Máximo de bucles de uso de herramientas de IA para evitar una ejecución descontrolada. Los agentes externos pueden tener sus propios límites de iteración internos que tienen prioridad.',
|
||||
'ai.safety.blocklist': 'Lista de bloqueo de comandos',
|
||||
'ai.safety.blocklist.description': 'Patrones regex para bloquear comandos peligrosos ejecutados mediante NetMesh.',
|
||||
'ai.safety.blocklist.placeholder': 'Patrón regex...',
|
||||
'ai.safety.blocklist.reset': 'Restablecer a los valores predeterminados',
|
||||
'ai.safety.blocklist.add': 'Agregar patrón',
|
||||
'ai.safety.grants.title': 'Memoria de permisos',
|
||||
'ai.safety.grants.heading': 'Reglas de permitir en modo Confirmar',
|
||||
'ai.safety.grants.description': 'El modo Confirmar pregunta antes de ejecutar una operación. Las reglas guardadas permiten automáticamente las operaciones que coinciden en todas las sesiones/nodos de terminal, y se pueden editar manualmente.',
|
||||
'ai.safety.grants.empty': 'Aún no hay reglas guardadas. Aprueba una herramienta con "Permitir siempre" o agrega una manualmente.',
|
||||
'ai.safety.grants.capability': 'Capacidad',
|
||||
'ai.safety.grants.sessionPattern': 'Patrón de sesión',
|
||||
'ai.safety.grants.commandPattern': 'Patrón de comando (opcional)',
|
||||
'ai.safety.grants.note': 'Nota (opcional)',
|
||||
'ai.safety.grants.add': 'Agregar regla',
|
||||
'ai.safety.grants.remove': 'Quitar',
|
||||
'ai.safety.grants.export': 'Exportar JSON',
|
||||
'ai.safety.grants.import': 'Importar JSON',
|
||||
'ai.safety.note': 'Estos ajustes de seguridad se aplican a las acciones que pasan por NetMesh. Las CLIs de agentes externos también pueden exponer herramientas locales regidas por el propio agente.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Agregar terminal',
|
||||
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'Historial',
|
||||
'terminal.layer.theme': 'Tema',
|
||||
'terminal.layer.notes': 'Notas',
|
||||
'terminal.layer.aiChat': 'Chat de IA',
|
||||
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
|
||||
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
|
||||
'terminal.layer.closePanel': 'Cerrar panel',
|
||||
'terminal.layer.closePane': 'Cerrar división',
|
||||
'terminal.layer.resizeSplit': 'Redimensionar división',
|
||||
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
|
||||
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
|
||||
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
|
||||
'terminal.layer.hostTree.search': 'Buscar hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Buscar',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
|
||||
'terminal.layer.hostTree.newHost': 'Nuevo host',
|
||||
'terminal.layer.hostTree.newHostInGroup': 'Nuevo host en este grupo',
|
||||
'terminal.layer.hostTree.editHost': 'Editar host',
|
||||
'terminal.layer.hostTree.hostSavedNextConnection': 'Host actualizado. Los ajustes de conexión se aplicarán la próxima vez que te conectes.',
|
||||
'terminal.layer.hostTree.newGroup': 'Nuevo grupo',
|
||||
'terminal.layer.hostTree.localShell': 'Shell local',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'No hay etiquetas disponibles',
|
||||
'terminal.layer.hostTree.clearTags': 'Borrar selección',
|
||||
'terminal.layer.hostTree.collapse': 'Contraer lista de hosts',
|
||||
'terminal.layer.hostTree.expand': 'Expandir lista de hosts',
|
||||
'terminal.layer.hostTree.empty': 'No se encontraron hosts',
|
||||
'terminal.layer.hostTree.details.host': 'Host',
|
||||
'terminal.layer.hostTree.details.user': 'Usuario',
|
||||
'terminal.layer.hostTree.details.port': 'Puerto',
|
||||
'terminal.layer.hostTree.details.protocol': 'Protocolo',
|
||||
'terminal.layer.hostTree.details.group': 'Grupo',
|
||||
'terminal.layer.hostTree.details.tags': 'Etiquetas',
|
||||
'terminal.layer.hostTree.details.lastConnected': 'Última conexión',
|
||||
'topTabs.openQuickSwitcher': 'Abrir selector rápido',
|
||||
'topTabs.moreTabs': 'Más pestañas',
|
||||
'topTabs.aiAssistant': 'Asistente de IA',
|
||||
'topTabs.newLocalTerminal': 'Nueva terminal local',
|
||||
'topTabs.controlPanel': 'Controles rápidos',
|
||||
'topTabs.controlPanel.externalMcp': 'MCP externo',
|
||||
'topTabs.controlPanel.theme': 'Tema',
|
||||
'topTabs.controlPanel.theme.light': 'Claro',
|
||||
'topTabs.controlPanel.theme.dark': 'Oscuro',
|
||||
'topTabs.controlPanel.theme.system': 'Sistema',
|
||||
'topTabs.externalMcp.enable': 'Habilitar MCP externo',
|
||||
'topTabs.externalMcp.disable': 'Desactivar MCP externo',
|
||||
'topTabs.windowOpacity': 'Opacidad de la ventana',
|
||||
'topTabs.openSettings': 'Abrir Configuración',
|
||||
'ai.chat.sessionHistory': 'Historial de sesiones',
|
||||
'ai.chat.resizeInput': 'Arrastra para redimensionar el campo de mensaje',
|
||||
'ai.chat.attach': 'Adjuntar',
|
||||
'ai.chat.terminalSelectionAttachment': 'Selección de la terminal',
|
||||
'ai.chat.terminalSelectionLines': 'líneas: {count}',
|
||||
'ai.chat.collapse': 'Contraer',
|
||||
'ai.chat.expand': 'Expandir',
|
||||
'ai.chat.enableAgent': 'Habilitar {name}',
|
||||
'ai.chat.artifact.noteFallback': 'Nota de Vault',
|
||||
'ai.chat.artifact.openNotes': 'Abrir Notas',
|
||||
'ai.chat.artifact.openHosts': 'Abrir Hosts',
|
||||
'ai.chat.artifact.notesSummary': '{count} notas en Vault',
|
||||
'ai.chat.artifact.hostsSummary': '{count} hosts en Vault',
|
||||
'ai.chat.artifact.hostsAdded': 'Se agregaron {count} hosts',
|
||||
'ai.chat.artifact.hostsPreview': 'Vista previa de {count} hosts',
|
||||
'ai.chat.artifact.failed': 'La operación de Vault falló',
|
||||
'ai.chat.artifact.unavailableTitle': 'No disponible',
|
||||
'ai.chat.artifact.noteMissing': 'Esta nota ya no está en tu Vault.',
|
||||
'ai.chat.artifact.hostMissing': 'Este host ya no está en tu Vault.',
|
||||
'ai.chat.artifact.snippetMissing': 'Este snippet o script ya no está en tu Vault.',
|
||||
'ai.chat.artifact.openSnippets': 'Abrir Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': '{count} snippets en Vault',
|
||||
'ai.chat.artifact.scriptsSummary': '{count} scripts en Vault',
|
||||
'ai.chat.artifact.snippetFallback': 'Snippet de Vault',
|
||||
'ai.chat.artifact.scriptFallback': 'Script de automatización',
|
||||
'ai.chat.artifact.scriptLanguage': 'Script de {language}',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet eliminado',
|
||||
'ai.chat.artifact.scriptDeleted': 'Script eliminado',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet ejecutado',
|
||||
'ai.chat.artifact.scriptStarted': 'Se inició la ejecución del script',
|
||||
'ai.chat.artifact.scriptRunStatus': 'Ejecución del script: {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} ejecuciones de scripts',
|
||||
'ai.chat.artifact.scriptRunStopped': 'Ejecución del script detenida',
|
||||
'ai.chat.artifact.scriptRunPaused': 'Ejecución del script en pausa',
|
||||
'ai.chat.artifact.scriptRunResumed': 'Ejecución del script reanudada',
|
||||
'ai.chat.artifact.scriptReference': 'referencia de la API de nct',
|
||||
'zmodem.waitingForRemote': 'Esperando al servidor remoto...',
|
||||
'zmodem.uploading': 'Subiendo',
|
||||
'zmodem.downloading': 'Descargando',
|
||||
'zmodem.cancelTransfer': 'Cancelar transferencia (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'El archivo remoto ya existe',
|
||||
'zmodem.overwrite.applyToRest': 'Aplicar a los conflictos restantes',
|
||||
'zmodem.overwrite.overwrite': 'Sobrescribir',
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
};
|
||||
1114
application/i18n/locales/es/core.ts
Normal file
1114
application/i18n/locales/es/core.ts
Normal file
File diff suppressed because it is too large
Load Diff
133
application/i18n/locales/es/scripts.ts
Normal file
133
application/i18n/locales/es/scripts.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export const esScriptsMessages = {
|
||||
'scripts.meta.name': 'Nombre',
|
||||
'scripts.meta.language': 'Lenguaje',
|
||||
'scripts.meta.description': 'Descripción',
|
||||
'scripts.meta.descriptionPlaceholder': 'Notas opcionales sobre este script',
|
||||
'scripts.meta.trigger': 'Disparador',
|
||||
'scripts.meta.triggerPattern': 'Patrón de salida (regex)',
|
||||
'scripts.meta.code': 'Script',
|
||||
'scripts.trigger.manual': 'Ejecución manual',
|
||||
'scripts.trigger.onConnect': 'Ejecutar al conectar',
|
||||
'scripts.trigger.onOutput': 'Ejecutar al coincidir con la salida',
|
||||
'scripts.trigger.onOutputHint': 'Se dispara cuando la salida del servidor coincide con el patrón (el eco de las teclas del usuario se ignora). Sin hosts de destino configurados, escucha en la sesión conectada actual; con hosts de destino, solo en esos hosts. Deshabilitado en aplicaciones de pantalla alterna como vim o htop. Se suprime mientras otro script se ejecuta en esta sesión; se vuelve a revisar cuando termina. Para esperas dentro del flujo, usa nct.screen.waitForText o nct.screen.waitForRegex en el código del script.',
|
||||
'scripts.actions.save': 'Guardar',
|
||||
'scripts.actions.runNow': 'Ejecutar ahora',
|
||||
'scripts.actions.openEditor': 'Abrir editor',
|
||||
'scripts.actions.openEditorHint': 'Editar el script en una ventana más grande',
|
||||
'scripts.editor.modalTitle': 'Editor de scripts',
|
||||
'scripts.editor.modalSubtitle': 'Edita los metadatos y el código del script en un espacio de trabajo más amplio.',
|
||||
'scripts.editor.lineCount': '{count} líneas',
|
||||
'scripts.editor.resize': 'Redimensionar el editor',
|
||||
'scripts.targets.hint': 'Los grupos seleccionados se resuelven dinámicamente, por lo que los hosts agregados después se incluyen automáticamente.',
|
||||
'scripts.targets.connectOrderHint': 'El orden de ejecución de los scripts de conexión se configura por host en Detalles del host → Automatización.',
|
||||
'scripts.targets.currentHostMismatch': 'Este script no está asignado al host actual.',
|
||||
'hostDetails.automation.groupScripts': 'Scripts de grupo heredados',
|
||||
'hostDetails.automation.groupScriptsHint': 'Estos scripts siguen al grupo del host dinámicamente y se ordenan antes de la cola específica del host.',
|
||||
'scripts.actions.runNowHint': 'Ejecutar en los destinos seleccionados, o en todos los hosts conectables cuando esa opción está habilitada.',
|
||||
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
|
||||
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
|
||||
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
|
||||
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
'scripts.running.status.paused': 'En pausa',
|
||||
'scripts.running.status.completed': 'Completado',
|
||||
'scripts.running.status.failed': 'Falló',
|
||||
'scripts.running.waitingFor': 'Esperando {pattern}',
|
||||
'scripts.running.waitingForLabel': 'Esperando',
|
||||
'scripts.running.waitingForShellPrompt': 'indicador del shell (# o $)',
|
||||
'scripts.running.lastSent': 'Enviado: {command}',
|
||||
'scripts.recording.start': 'Comenzar grabación',
|
||||
'scripts.recording.active': 'Detener grabación',
|
||||
'scripts.recording.startHint': 'Graba las acciones en la terminal enfocada y genera código de script nct',
|
||||
'scripts.recording.unavailableHint': 'La grabación no está disponible aquí — usa la barra lateral de scripts a la derecha',
|
||||
'scripts.recording.activeHint': 'Grabando esta terminal. Escribe comandos como de costumbre; haz clic en Detener o usa el control REC en la barra de herramientas para finalizar y guardar.',
|
||||
'scripts.recording.started': 'Grabación iniciada — opera en la terminal',
|
||||
'scripts.recording.noSession': 'Conecta una terminal primero (pestaña única o espacio de trabajo)',
|
||||
'scripts.recording.alreadyActive': 'Otra terminal ya está grabando',
|
||||
'scripts.recording.stop': 'Detener grabación',
|
||||
'scripts.recording.pause': 'Pausar grabación',
|
||||
'scripts.recording.resume': 'Reanudar grabación',
|
||||
'scripts.recording.saveTitle': 'Guardar script grabado',
|
||||
'scripts.recording.namePlaceholder': 'Nombre del script',
|
||||
'scripts.recording.packagePlaceholder': 'Guardar en carpeta',
|
||||
'scripts.recording.rootPackage': 'Raíz',
|
||||
'scripts.recording.save': 'Guardar',
|
||||
'scripts.recording.saveAndEdit': 'Guardar y editar',
|
||||
'scripts.recording.helpTitle': 'Cómo grabar un script',
|
||||
'scripts.recording.helpIntro': 'La grabación convierte lo que haces en la terminal en un script de automatización reutilizable — útil para despliegues, verificaciones de salud y otras tareas repetitivas.',
|
||||
'scripts.recording.helpStep1': 'Conecta a un host. Una pestaña de terminal independiente o una terminal dentro de un espacio de trabajo funcionan igual — abre la barra lateral de scripts a la derecha.',
|
||||
'scripts.recording.helpStep2': 'Haz clic en Comenzar grabación. Una insignia REC roja en la barra de herramientas de la terminal indica que la grabación está activa.',
|
||||
'scripts.recording.helpStep3': 'Escribe comandos en la terminal como lo harías normalmente. Cada pulsación de Enter se captura como un paso.',
|
||||
'scripts.recording.helpStep4': 'Cuando termines, haz clic en Detener grabación, o usa el control de detención junto a REC en la barra de herramientas de la terminal.',
|
||||
'scripts.recording.helpStep5': 'Nombra el script y guárdalo. Elige Guardar y editar si quieres ajustar el código generado en el editor de scripts.',
|
||||
'scripts.recording.helpTipsTitle': 'Consejos',
|
||||
'scripts.recording.helpTip1': 'Las pausas de más de 1 segundo entre acciones se registran como tiempo de espera para que la reproducción no sea demasiado rápida.',
|
||||
'scripts.recording.helpTip2': 'Después de cada comando, la grabación espera un indicador del shell (como $ o #) antes del siguiente paso.',
|
||||
'scripts.recording.helpTip3': 'La entrada de contraseñas se marca como sensible y no se almacena en texto plano en el script.',
|
||||
'scripts.recording.helpTip4': 'Para vim, menús u otros flujos interactivos, graba primero los comandos principales y luego refina el script manualmente.',
|
||||
'scripts.dialog.title': 'Script',
|
||||
'scripts.dialog.ok': 'Aceptar',
|
||||
'scripts.dialog.required': 'Obligatorio',
|
||||
'scripts.dialog.numberInvalid': 'Ingresa un número válido',
|
||||
'scripts.dialog.numberMin': 'Debe ser al menos {min}',
|
||||
'scripts.dialog.numberMax': 'Debe ser como máximo {max}',
|
||||
'scripts.dialog.numberStep': 'Debe usar incrementos de {step}',
|
||||
'scripts.dialog.waitForTimeoutTitle': 'La espera agotó el tiempo',
|
||||
'scripts.dialog.retry': 'Reintentar',
|
||||
'scripts.dialog.skip': 'Omitir',
|
||||
'scripts.dialog.abort': 'Abortar',
|
||||
'scripts.running.stepProgress': 'Paso {current} / {total}',
|
||||
'scripts.running.determinateProgress': '{label} {current}/{total}',
|
||||
'scripts.running.progressFallback': 'Progreso',
|
||||
'scripts.running.operationsCount': '{count} operaciones',
|
||||
'scripts.running.opsPrefix': '',
|
||||
'scripts.running.opsSuffix': ' operaciones',
|
||||
'scripts.running.elapsedLabel': 'Transcurrido',
|
||||
'scripts.running.lastSentLabel': 'Enviado:',
|
||||
'scripts.running.elapsed': '{elapsed}',
|
||||
'scripts.running.completedSummary': 'Completado · {count} operaciones · {elapsed}',
|
||||
'scripts.running.dismissHint': 'Toca cerrar para descartar',
|
||||
'scripts.running.dismiss': 'Cerrar',
|
||||
'scripts.running.viewLogs': 'Ver registros',
|
||||
'scripts.running.logTitle': '{name} · Registro de ejecución',
|
||||
'scripts.running.logEmpty': 'Aún no hay salida de registro',
|
||||
'scripts.running.pause': 'Pausar',
|
||||
'scripts.running.resume': 'Reanudar',
|
||||
'scripts.running.stop': 'Detener',
|
||||
'scripts.recording.saved': 'Script guardado',
|
||||
'scripts.recording.savedNamed': 'Guardado "{name}"',
|
||||
'scripts.observer.blocked': 'El modo observador bloquea los scripts que escriben en la terminal.',
|
||||
'vault.section.scripts': 'Scripts',
|
||||
'vault.nav.scripts': 'Scripts',
|
||||
'snippets.action.newScript': 'Nuevo script de automatización',
|
||||
'hostDetails.section.automation': 'Automatización',
|
||||
'hostDetails.automation.loginScript': 'Script de inicio de sesión',
|
||||
'hostDetails.automation.loginScriptPlaceholder': 'Selecciona un script',
|
||||
'hostDetails.automation.none': 'Ninguno',
|
||||
'hostDetails.automation.outputTriggers': 'Disparadores de salida',
|
||||
'hostDetails.automation.addTrigger': 'Agregar disparador',
|
||||
'hostDetails.automation.triggerPatternPlaceholder': 'Patrón de regex',
|
||||
'hostDetails.automation.linkedScripts': 'Scripts vinculados',
|
||||
'hostDetails.automation.linkedScriptsEmpty': 'Aún no hay scripts vinculados a este host.',
|
||||
'hostDetails.automation.linkScriptPlaceholder': 'Vincular script existente…',
|
||||
'hostDetails.automation.unlink': 'Desvincular',
|
||||
'hostDetails.automation.equivalenceHint': 'Los vínculos agregan este host a la lista de destinos del script en Scripts. Ambas vistas se mantienen sincronizadas.',
|
||||
'hostDetails.automation.queueHint': 'Los scripts se ejecutan en orden cuando este host se conecta. Los scripts globales se ejecutan primero, luego esta cola.',
|
||||
'hostDetails.automation.globalScripts': 'Scripts de conexión globales',
|
||||
'hostDetails.automation.globalScriptsHint': 'Se ejecutan primero en cada conexión. Reordena según el orden de clasificación de la biblioteca de Scripts.',
|
||||
'hostDetails.automation.connectQueue': 'Cola de ejecución de este host',
|
||||
'hostDetails.automation.connectQueueEmpty': 'Aún no hay scripts de conexión en cola para este host.',
|
||||
'hostDetails.automation.addToQueuePlaceholder': 'Agregar script a la cola…',
|
||||
'hostDetails.automation.moveUp': 'Subir',
|
||||
'hostDetails.automation.moveDown': 'Bajar',
|
||||
'hostDetails.automation.removeFromQueue': 'Quitar de la cola',
|
||||
'hostDetails.automation.dragHandle': 'Arrastra para reordenar',
|
||||
'hostDetails.automation.queueDragHint': 'Arrastra los elementos para reordenar la cola de conexión.',
|
||||
};
|
||||
261
application/i18n/locales/es/systemManager.ts
Normal file
261
application/i18n/locales/es/systemManager.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'Sistema',
|
||||
|
||||
'systemManager.noSession': 'No hay una sesión de terminal activa.',
|
||||
'systemManager.notConnected': 'Conéctate a un host para administrar procesos y servicios.',
|
||||
'systemManager.empty': 'No hay datos disponibles.',
|
||||
'systemManager.tabs.overview': 'Resumen',
|
||||
'systemManager.tabs.processes': 'Procesos',
|
||||
'systemManager.tabs.ports': 'Puertos',
|
||||
'systemManager.tabs.services': 'Servicios',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'Secciones del administrador del sistema',
|
||||
'systemManager.popup.loading': 'Abriendo terminal…',
|
||||
'systemManager.popup.startupFailed': 'El comando de inicio no se completó correctamente. Verifica que el destino siga disponible e inténtalo de nuevo.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'No se pudieron cargar los procesos',
|
||||
'systemManager.errors.loadTmux': 'No se pudieron cargar las sesiones de tmux',
|
||||
'systemManager.errors.loadTmuxWindows': 'No se pudieron cargar las ventanas de tmux',
|
||||
'systemManager.errors.loadTmuxPanes': 'No se pudieron cargar los paneles de tmux',
|
||||
'systemManager.errors.loadTmuxClients': 'No se pudieron cargar los clientes de tmux',
|
||||
'systemManager.errors.actionFailed': 'La acción falló',
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
'systemManager.overview.loading': 'Cargando resumen del sistema…',
|
||||
'systemManager.overview.memory': 'Memoria',
|
||||
'systemManager.overview.disk': 'Disco',
|
||||
'systemManager.overview.network': 'Red',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} núcleos',
|
||||
'systemManager.overview.load': 'Carga',
|
||||
'systemManager.overview.uptime': 'Tiempo activo',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}m',
|
||||
'systemManager.overview.system': 'Sistema',
|
||||
'systemManager.overview.kernel': 'Kernel',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Latencia de red SSH',
|
||||
'systemManager.overview.cpuCores': 'Núcleos de CPU',
|
||||
'systemManager.overview.disks': 'Discos',
|
||||
'systemManager.overview.interfaces': 'Interfaces de red',
|
||||
'systemManager.overview.topProcesses': 'Procesos con más memoria',
|
||||
'systemManager.overview.noData': 'Sin datos',
|
||||
'systemManager.overview.noDisks': 'Sin datos de disco',
|
||||
'systemManager.overview.noInterfaces': 'Sin datos de interfaces',
|
||||
'systemManager.overview.noTopProcesses': 'Sin datos de procesos',
|
||||
|
||||
'systemManager.processes.search': 'Buscar procesos…',
|
||||
'systemManager.processes.command': 'Comando',
|
||||
'systemManager.processes.user': 'Usuario',
|
||||
'systemManager.processes.term': 'Terminar',
|
||||
'systemManager.processes.kill': 'Forzar cierre',
|
||||
'systemManager.processes.stop': 'Detener (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Continuar (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Colgar (SIGHUP)',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Valor de nice (-20 a 19)',
|
||||
'systemManager.processes.reniceInvalid': 'El valor de nice debe estar entre -20 y 19',
|
||||
'systemManager.processes.confirmKill': '¿Enviar SIGKILL al proceso {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': '¿Enviar SIG{{signal}} al proceso {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'Todos',
|
||||
'systemManager.processes.filter.running': 'En ejecución',
|
||||
'systemManager.processes.ppid': 'PID padre',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Tamaño virtual',
|
||||
'systemManager.processes.elapsed': 'Transcurrido',
|
||||
'systemManager.processes.stat': 'Estado',
|
||||
'systemManager.processes.meta': '{{count}} proceso(s)',
|
||||
'systemManager.processes.loading': 'Cargando procesos…',
|
||||
'systemManager.processes.loadingMore': 'Cargando más procesos…',
|
||||
'systemManager.processes.state.running': 'En ejecución',
|
||||
'systemManager.processes.state.sleeping': 'En reposo',
|
||||
'systemManager.processes.state.stopped': 'Detenido',
|
||||
'systemManager.processes.state.zombie': 'Zombie',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'MEM',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Comando',
|
||||
'systemManager.processes.sort.user': 'Usuario',
|
||||
|
||||
'systemManager.common.dismiss': 'Descartar',
|
||||
'systemManager.common.checkingAvailability': 'Verificando disponibilidad…',
|
||||
'systemManager.common.loading': 'Cargando…',
|
||||
'systemManager.common.loadingDetails': 'Cargando detalles…',
|
||||
'systemManager.common.loadingStats': 'Cargando estadísticas…',
|
||||
|
||||
'systemManager.tmux.new': 'Nuevo',
|
||||
'systemManager.tmux.search': 'Buscar sesiones…',
|
||||
'systemManager.tmux.newSessionTitle': 'Nueva sesión de tmux',
|
||||
'systemManager.tmux.newSessionDesc': 'Ponle nombre a la sesión y, opcionalmente, ejecuta un script al iniciar.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Comando personalizado',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'Desde snippet',
|
||||
'systemManager.tmux.pickSnippet': 'Desde snippets',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'Aún no hay snippets; agrega algunos en el panel de Scripts o en Vault.',
|
||||
'systemManager.tmux.selectedSnippet': 'Usando snippet: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Nombre de la sesión',
|
||||
'systemManager.tmux.newSessionCommand': 'Comando de inicio',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'p. ej. htop o npm run dev (opcional)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Déjalo vacío para una sesión de shell predeterminada.',
|
||||
'systemManager.tmux.creating': 'Creando…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'mi-sesion',
|
||||
'systemManager.tmux.newSessionRequired': 'Primero ingresa un nombre de sesión',
|
||||
'systemManager.tmux.empty': 'No hay sesiones de tmux',
|
||||
'systemManager.tmux.attach': 'Conectar',
|
||||
'systemManager.tmux.attached': 'Conectada',
|
||||
'systemManager.tmux.detached': 'Desacoplada',
|
||||
'systemManager.tmux.windows': '{{count}} ventana(s)',
|
||||
'systemManager.tmux.created': 'Creada',
|
||||
'systemManager.tmux.activity': 'Actividad',
|
||||
'systemManager.tmux.rename': 'Renombrar',
|
||||
'systemManager.tmux.detach': 'Desacoplar todo',
|
||||
'systemManager.tmux.killSession': 'Terminar sesión',
|
||||
'systemManager.tmux.killServer': 'Terminar servidor',
|
||||
'systemManager.tmux.loadingDetails': 'Cargando detalles…',
|
||||
'systemManager.tmux.clients': 'Clientes conectados',
|
||||
'systemManager.tmux.windowList': 'Ventanas',
|
||||
'systemManager.tmux.newWindow': 'Nueva ventana',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
'systemManager.tmux.panes': '{{count}} panel(es)',
|
||||
'systemManager.tmux.active': 'activo',
|
||||
'systemManager.tmux.unnamedWindow': 'Ventana sin nombre',
|
||||
'systemManager.tmux.unnamedPane': 'Panel sin nombre',
|
||||
'systemManager.tmux.attachWindow': 'Conectar a la ventana',
|
||||
'systemManager.tmux.selectWindow': 'Seleccionar ventana',
|
||||
'systemManager.tmux.killWindow': 'Terminar ventana',
|
||||
'systemManager.tmux.killPane': 'Terminar panel',
|
||||
'systemManager.tmux.splitHorizontal': 'Dividir horizontal',
|
||||
'systemManager.tmux.splitVertical': 'Dividir vertical',
|
||||
'systemManager.tmux.sendKeys': 'Enviar teclas',
|
||||
'systemManager.tmux.sendKeysTo': 'Enviar teclas a la ventana {{window}} panel {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Comando o texto…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Renombrar sesión',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Renombrar ventana',
|
||||
'systemManager.tmux.windowName': 'Nombre de la ventana',
|
||||
'systemManager.tmux.confirmKillSession': '¿Terminar la sesión de tmux "{{name}}"?',
|
||||
'systemManager.tmux.confirmDetachSession': '¿Desacoplar todos los clientes de "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillWindow': '¿Terminar la ventana "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillPane': '¿Terminar el panel #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': '¿Terminar el servidor de tmux? Se terminarán todas las sesiones.',
|
||||
'systemManager.tmux.meta': '{{count}} sesión(es)',
|
||||
|
||||
'systemManager.docker.title': 'Contenedores',
|
||||
'systemManager.docker.subTabs.containers': 'Contenedores',
|
||||
'systemManager.docker.subTabs.images': 'Imágenes',
|
||||
'systemManager.docker.empty': 'No se encontraron contenedores',
|
||||
'systemManager.docker.imagesEmpty': 'No se encontraron imágenes',
|
||||
'systemManager.docker.search': 'Buscar contenedores…',
|
||||
'systemManager.docker.searchImages': 'Buscar imágenes…',
|
||||
'systemManager.docker.filter.all': 'Todos',
|
||||
'systemManager.docker.filter.running': 'En ejecución',
|
||||
'systemManager.docker.filter.stopped': 'Detenidos',
|
||||
'systemManager.docker.filter.paused': 'En pausa',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Registros',
|
||||
'systemManager.docker.details': 'Detalles',
|
||||
'systemManager.docker.inspect': 'Inspeccionar',
|
||||
'systemManager.docker.imageInspect': 'Inspección de imagen',
|
||||
'systemManager.docker.confirmRemove': '¿Eliminar este contenedor?',
|
||||
'systemManager.docker.confirmKill': '¿Terminar por la fuerza este contenedor?',
|
||||
'systemManager.docker.confirmRemoveImage': '¿Eliminar la imagen "{{name}}"?',
|
||||
'systemManager.docker.confirmPrune': '¿Eliminar las imágenes colgantes?',
|
||||
'systemManager.docker.confirmPruneAll': '¿Eliminar todas las imágenes no utilizadas?',
|
||||
'systemManager.docker.pause': 'Pausar',
|
||||
'systemManager.docker.unpause': 'Reanudar',
|
||||
'systemManager.docker.restart': 'Reiniciar',
|
||||
'systemManager.docker.kill': 'Terminar',
|
||||
'systemManager.docker.renamePrompt': 'Nombre del contenedor',
|
||||
'systemManager.docker.prune': 'Limpiar',
|
||||
'systemManager.docker.pruneAll': 'Limpiar todo',
|
||||
'systemManager.docker.tag': 'Etiqueta',
|
||||
'systemManager.docker.tagRepoPrompt': 'Nombre del repositorio',
|
||||
'systemManager.docker.tagNamePrompt': 'Nombre de la etiqueta',
|
||||
'systemManager.docker.meta': '{{count}} contenedor(es)',
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
'systemManager.ports.empty': 'No se reportaron puertos en escucha.',
|
||||
'systemManager.ports.search': 'Buscar puertos…',
|
||||
'systemManager.ports.meta': '{{count}} en escucha',
|
||||
'systemManager.ports.filter.all': 'Todos',
|
||||
'systemManager.ports.unknownProcess': 'Proceso desconocido',
|
||||
'systemManager.ports.terminate': 'Terminar',
|
||||
'systemManager.ports.confirmTerminate': '¿Enviar SIGTERM al proceso {{pid}} que ocupa este puerto?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl no está disponible en este host.',
|
||||
'systemManager.services.loading': 'Cargando servicios de systemd…',
|
||||
'systemManager.services.empty': 'No se encontraron servicios de systemd.',
|
||||
'systemManager.services.search': 'Buscar servicios…',
|
||||
'systemManager.services.meta': '{{count}} servicio(s)',
|
||||
'systemManager.services.filter.all': 'Todos',
|
||||
'systemManager.services.filter.running': 'En ejecución',
|
||||
'systemManager.services.filter.failed': 'Con error',
|
||||
'systemManager.services.filter.inactive': 'Inactivo',
|
||||
'systemManager.services.start': 'Iniciar',
|
||||
'systemManager.services.stop': 'Detener',
|
||||
'systemManager.services.restart': 'Reiniciar',
|
||||
'systemManager.services.enable': 'Habilitar',
|
||||
'systemManager.services.disable': 'Deshabilitar',
|
||||
'systemManager.services.reload': 'Recargar',
|
||||
'systemManager.services.scope.user': 'usuario',
|
||||
'systemManager.services.confirmAction': '¿{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'No se detectaron herramientas de GPU NVIDIA o NPU Ascend en este host.',
|
||||
'systemManager.gpu.loading': 'Cargando estadísticas del acelerador…',
|
||||
'systemManager.gpu.empty': 'Las herramientas del acelerador están presentes, pero no se reportó ningún dispositivo.',
|
||||
'systemManager.gpu.meta': '{{devices}} dispositivo(s) · {{processes}} proceso(s)',
|
||||
'systemManager.gpu.devices': 'Dispositivos',
|
||||
'systemManager.gpu.processes': 'Procesos de cómputo',
|
||||
'systemManager.gpu.noProcesses': 'No se reportaron procesos de cómputo.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Util',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Temperatura',
|
||||
'systemManager.gpu.power': 'Energía',
|
||||
'systemManager.gpu.fan': 'Ventilador {{value}}%',
|
||||
'systemManager.gpu.driver': 'Controlador {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Estado',
|
||||
'systemManager.inspect.image': 'Imagen',
|
||||
'systemManager.inspect.created': 'Creado',
|
||||
'systemManager.inspect.started': 'Iniciado',
|
||||
'systemManager.inspect.restartPolicy': 'Política de reinicio',
|
||||
'systemManager.inspect.command': 'Comando',
|
||||
'systemManager.inspect.ports': 'Puertos',
|
||||
'systemManager.inspect.networks': 'Redes',
|
||||
'systemManager.inspect.mounts': 'Montajes',
|
||||
'systemManager.inspect.env': 'Entorno',
|
||||
'systemManager.inspect.labels': 'Etiquetas',
|
||||
'systemManager.inspect.tags': 'Etiquetas',
|
||||
'systemManager.inspect.digests': 'Digests',
|
||||
'systemManager.inspect.size': 'Tamaño',
|
||||
'systemManager.inspect.platform': 'Plataforma',
|
||||
'systemManager.inspect.workdir': 'Directorio de trabajo',
|
||||
'systemManager.inspect.exposedPorts': 'Puertos expuestos',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Ocultar JSON',
|
||||
};
|
||||
855
application/i18n/locales/es/terminal.ts
Normal file
855
application/i18n/locales/es/terminal.ts
Normal file
@@ -0,0 +1,855 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
|
||||
'terminal.passwordPicker.title': 'Contraseñas guardadas',
|
||||
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
|
||||
// Network Device Mode auto-detection tip (session header)
|
||||
'terminal.networkDevice.tip.message': 'Esto parece un dispositivo de red. Activa el Modo de Dispositivo de Red para enviar comandos tal cual (sin envoltorio de shell).',
|
||||
'terminal.networkDevice.tip.action': 'Activar',
|
||||
'terminal.networkDevice.tip.dismiss': 'Descartar',
|
||||
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Abrir SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
|
||||
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Más acciones',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Historial de comandos',
|
||||
'terminal.toolbar.configureOsc7': 'Configurar seguimiento de directorio',
|
||||
'history.scope.label': 'Alcance del historial',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
'history.searchPlaceholder': 'Buscar en el historial...',
|
||||
'history.loading': 'Cargando historial remoto...',
|
||||
'history.meta.count': '{count} comandos',
|
||||
'history.empty.noSession': 'Abre una sesión remota para ver su historial de comandos.',
|
||||
'history.empty.unsupportedProtocol': 'El historial de comandos solo está disponible para sesiones SSH/Mosh/ET.',
|
||||
'history.empty.noHistory': 'No se encontró historial de comandos en este host.',
|
||||
'history.empty.noGlobalHistory': 'Aún no hay historial global de comandos. Los comandos que ejecutes aparecerán aquí.',
|
||||
'history.action.refresh': 'Actualizar',
|
||||
'history.action.retry': 'Reintentar',
|
||||
'history.action.paste': 'Pegar en la terminal',
|
||||
'history.action.run': 'Ejecutar en la terminal',
|
||||
'history.action.saveAsSnippet': 'Guardar como snippet',
|
||||
'history.action.delete': 'Eliminar del historial',
|
||||
'terminal.toolbar.library': 'Biblioteca',
|
||||
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
|
||||
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
|
||||
'terminal.toolbar.searchTerminal': 'Buscar en la terminal',
|
||||
'terminal.toolbar.search': 'Buscar',
|
||||
'terminal.toolbar.startSessionLog': 'Iniciar registro de sesión',
|
||||
'terminal.toolbar.stopSessionLog': 'Detener registro de sesión',
|
||||
'terminal.toolbar.timestampsEnable': 'Mostrar marcas de tiempo',
|
||||
'terminal.toolbar.timestampsDisable': 'Ocultar marcas de tiempo',
|
||||
'terminal.toolbar.broadcast': 'Difundir',
|
||||
'terminal.toolbar.broadcastEnable': 'Activar Modo de Difusión',
|
||||
'terminal.toolbar.broadcastDisable': 'Desactivar Modo de Difusión',
|
||||
'terminal.toolbar.composeBar': 'Barra de redacción',
|
||||
'terminal.composeBar.placeholder': 'Escribe el comando aquí y presiona Enter para enviar...',
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Fija snippets con + para acceso rápido',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No hay snippets que coincidan',
|
||||
'terminal.composeBar.pinnedCount': '{count} fijados',
|
||||
'terminal.composeBar.unpinSnippet': 'Quitar {label} de la barra rápida',
|
||||
'terminal.composeBar.snippetClickHint': 'Clic para insertar · Shift+Clic para enviar',
|
||||
'terminal.toolbar.focus': 'Enfocar',
|
||||
'terminal.toolbar.focusMode': 'Modo de Enfoque',
|
||||
'terminal.paneMagnification.magnify': 'Ampliar panel actual',
|
||||
'terminal.paneMagnification.restore': 'Restaurar diseño de paneles',
|
||||
'terminal.paneMagnification.hint': 'Ampliado',
|
||||
'terminal.toolbar.detach': 'Desacoplar a pestaña independiente',
|
||||
'terminal.toolbar.dragPane': 'Arrastrar panel de terminal',
|
||||
'terminal.toolbar.showActions': 'Mostrar acciones de la terminal',
|
||||
'terminal.toolbar.encoding': 'Codificación de la terminal',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Cerrar sesión',
|
||||
'terminal.toolbar.hostHighlight.title': 'Resaltado de palabras clave del host',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No hay reglas de resaltado personalizadas definidas para este host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Agregar nueva regla',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Etiqueta (p. ej., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Patrón regex (p. ej., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Patrón regex no válido',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Borrar todo',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Cambiar el color de resaltado de',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Seleccionar el color de la nueva regla',
|
||||
'terminal.statusbar.copyHostname.label': 'Copiar dirección del host',
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Copiar dirección del host ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Dirección del host copiada: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'No se pudo copiar la dirección del host al portapapeles',
|
||||
'terminal.statusbar.disconnect.label': 'Desconectar',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Desconectar esta sesión sin cerrar la pestaña',
|
||||
'terminal.statusbar.reconnect.label': 'Reconectar',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'terminal.serverStats.memory': 'Uso de memoria',
|
||||
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
'terminal.serverStats.memBuffers': 'Búfers',
|
||||
'terminal.serverStats.memCached': 'Caché',
|
||||
'terminal.serverStats.memFree': 'Libre',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap usada',
|
||||
'terminal.serverStats.swapFree': 'Swap libre',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Principales procesos por memoria',
|
||||
'terminal.serverStats.disk': 'Uso de disco',
|
||||
'terminal.serverStats.diskDetails': 'Discos montados',
|
||||
'terminal.serverStats.network': 'Velocidad de red',
|
||||
'terminal.serverStats.latency': 'Latencia de red SSH',
|
||||
'terminal.serverStats.networkDetails': 'Interfaces de red',
|
||||
'terminal.serverStats.noData': 'No hay datos disponibles',
|
||||
'terminal.dragDrop.localTitle': 'Soltar para insertar rutas',
|
||||
'terminal.dragDrop.localMessage': 'Las rutas de los archivos se insertarán en la terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Soltar para subir archivos',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Los archivos se subirán mediante ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Los archivos se subirán mediante SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No hay archivos para subir',
|
||||
'terminal.dragDrop.notConnected': 'No se pueden soltar archivos: la terminal no está conectada',
|
||||
'terminal.dragDrop.errorTitle': 'Error al soltar',
|
||||
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
|
||||
'terminal.dragDrop.destinationUnknown': 'No se pudo determinar la carpeta actual del terminal. Active el seguimiento de directorios o abra SFTP y elija primero una carpeta de carga.',
|
||||
'terminal.dragDrop.uploadCancelled': 'La carga se canceló porque la conexión del terminal cambió o no se pudo reutilizar. Vuelva a soltar los archivos después de reconectar.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'Esta carpeta no es escribible con el usuario de inicio de sesión. Active la elevación Sudo en la configuración del host, o vuelva al directorio de usuario y suelte de nuevo.',
|
||||
'terminal.search.placeholder': 'Buscar...',
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
|
||||
'terminal.menu.copy': 'Copiar',
|
||||
'terminal.menu.paste': 'Pegar',
|
||||
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
|
||||
'terminal.menu.addSelectionToAI': 'Agregar a la conversación',
|
||||
'terminal.menu.pasteSelection': 'Pegar selección',
|
||||
'terminal.menu.selectAll': 'Seleccionar todo',
|
||||
'terminal.menu.reconnect': 'Reconectar',
|
||||
'terminal.menu.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Recibir con YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Dividir en horizontal',
|
||||
'terminal.menu.splitVertical': 'Dividir en vertical',
|
||||
'terminal.menu.clearBuffer': 'Limpiar búfer',
|
||||
'terminal.menu.closeTerminal': 'Cerrar terminal',
|
||||
'terminal.menu.rename': 'Renombrar',
|
||||
'terminal.menu.detach': 'Desacoplar del espacio de trabajo',
|
||||
'terminal.menu.detachSession': 'Desacoplar {name}',
|
||||
'terminal.clipboardImageUpload.noImage': 'El portapapeles no contiene una imagen',
|
||||
'terminal.clipboardImageUpload.failed': 'No se pudo subir la imagen del portapapeles',
|
||||
'terminal.osc7Setup.title': 'Configurar seguimiento de directorio',
|
||||
'terminal.osc7Setup.desc': 'NetMesh agregará hooks de prompt OSC 7 para el usuario remoto actual. Esto ayuda a que SFTP siga el directorio de la terminal después de sudo o su.',
|
||||
'terminal.osc7Setup.targets': 'Posibles archivos a actualizar',
|
||||
'terminal.osc7Setup.command': 'Comando a ejecutar',
|
||||
'terminal.osc7Setup.run': 'Ejecutar configuración',
|
||||
'terminal.osc7Setup.running': 'Configurando...',
|
||||
'terminal.osc7Setup.configured': 'Seguimiento de directorio configurado',
|
||||
'terminal.osc7Setup.failed': 'Falló la configuración del seguimiento de directorio',
|
||||
'terminal.osc7Setup.sent': 'Configuración de seguimiento de directorio enviada a la terminal',
|
||||
'terminal.ymodem.selectFile': 'Selecciona el archivo a enviar',
|
||||
'terminal.ymodem.allFiles': 'Todos los archivos',
|
||||
'terminal.ymodem.started': 'YMODEM enviando {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM envió {fileName}',
|
||||
'terminal.ymodem.failed': 'Falló el envío con YMODEM',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Selecciona la carpeta para guardar los archivos recibidos',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM recibiendo...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM recibió {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM recibió {count} archivos',
|
||||
'terminal.ymodem.receiveEmpty': 'No se recibieron archivos YMODEM',
|
||||
'terminal.ymodem.receiveFailed': 'Falló la recepción con YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM no está disponible',
|
||||
'terminal.selection.addToAI': 'Agregar a la conversación',
|
||||
'terminal.selection.addToAIDesc': 'Adjuntar la salida seleccionada de la terminal al borrador de IA',
|
||||
'terminal.auth.password': 'Contraseña',
|
||||
'terminal.auth.sshKey': 'Clave SSH',
|
||||
'terminal.auth.username': 'Nombre de usuario',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': 'Contraseña',
|
||||
'terminal.auth.password.placeholder': 'Ingresa la contraseña',
|
||||
'terminal.auth.passphrase': 'Frase de contraseña',
|
||||
'terminal.auth.passphrase.placeholder': 'Frase de contraseña opcional para la clave privada seleccionada',
|
||||
'terminal.auth.certificate': 'Certificado',
|
||||
'terminal.auth.selectKey': 'Seleccionar clave',
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Las credenciales del proxy no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresar la contraseña del proxy.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'La clave SSH guardada no está disponible en este dispositivo. Se usará autenticación por contraseña como respaldo.',
|
||||
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
|
||||
'terminal.progress.disconnected': 'Desconectado',
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
'terminal.progress.reconnecting': 'Reconectando...',
|
||||
'terminal.progress.autoReconnectScheduled': 'Conexión perdida. Reconectando en {seconds}s (intento {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Intento de reconexión automática {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Descartar aviso de desconexión',
|
||||
'terminal.connection.chainOf': 'Cadena {current} de {total}',
|
||||
'terminal.connection.showLogs': 'Mostrar registros',
|
||||
'terminal.connection.hideLogs': 'Ocultar registros',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.connection.protocol.plugin': 'Conexión de plugin',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal no admite actualmente la configuración de proxy de NetMesh. Usa SSH o quita el proxy de este host.',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal admite actualmente como máximo un host de salto en NetMesh.',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Shell local',
|
||||
'terminal.hostKey.unknownTitle': 'Confirmar esta clave de host',
|
||||
'terminal.hostKey.changedTitle': 'La clave del host cambió',
|
||||
'terminal.hostKey.unknownDescription': 'Aún no se puede establecer la autenticidad de {host}.',
|
||||
'terminal.hostKey.changedDescription': 'La clave guardada de {host} ya no coincide con este servidor.',
|
||||
'terminal.hostKey.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': 'Huella guardada',
|
||||
'terminal.hostKey.unknownHint': 'Recuérdala si esta huella pertenece al servidor que esperabas.',
|
||||
'terminal.hostKey.changedHint': 'Continúa solo si esperabas que este host cambiara.',
|
||||
'terminal.hostKey.addAndContinue': 'Agregar y continuar',
|
||||
'terminal.hostKey.updateAndContinue': 'Actualizar y continuar',
|
||||
'terminal.themeModal.title': 'Apariencia de la terminal',
|
||||
'terminal.themeModal.tab.theme': 'Tema',
|
||||
'terminal.themeModal.tab.font': 'Fuente',
|
||||
'terminal.themeModal.tab.custom': 'Personalizado',
|
||||
'terminal.themeModal.globalTheme': 'Tema global',
|
||||
'terminal.themeModal.globalFont': 'Fuente global',
|
||||
'terminal.themeModal.fontSize': 'Tamaño de fuente',
|
||||
'terminal.themeModal.fontWeight': 'Grosor de fuente',
|
||||
'terminal.themeModal.livePreview': 'Vista previa en vivo',
|
||||
'terminal.themeModal.themeType': 'Tema {type}',
|
||||
'terminal.hiddenTheme.title': 'Tema oculto actual',
|
||||
'terminal.hiddenTheme.desc': 'Este tema está oculto de la selección manual y se reemplazará cuando elijas otro tema.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'El tema del sistema está activo',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Abre Configuración para elegir un tema fijo de Claro u Oscuro.',
|
||||
'topTabs.toggleTheme.openSettings': 'Abrir Configuración',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Temas personalizados',
|
||||
'terminal.customTheme.yourThemes': 'Tus temas',
|
||||
'terminal.customTheme.new': 'Nuevo tema',
|
||||
'terminal.customTheme.newDesc': 'Clonar el tema actual y personalizarlo',
|
||||
'terminal.customTheme.newTitle': 'Nuevo tema personalizado',
|
||||
'terminal.customTheme.editTitle': 'Editar tema',
|
||||
'terminal.customTheme.import': 'Importar .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Importar desde archivo de esquema de color de iTerm2',
|
||||
'terminal.customTheme.importError': 'No se pudo analizar el archivo seleccionado. Asegúrate de que sea un archivo XML .itermcolors válido.',
|
||||
'terminal.customTheme.delete': 'Eliminar tema',
|
||||
'terminal.customTheme.confirmDelete': 'Confirmar eliminación',
|
||||
'terminal.customTheme.name': 'Nombre',
|
||||
'terminal.customTheme.namePlaceholder': 'Mi tema personalizado',
|
||||
'terminal.customTheme.type': 'Tipo',
|
||||
'terminal.customTheme.group.general': 'General',
|
||||
'terminal.customTheme.group.normal': 'Colores normales',
|
||||
'terminal.customTheme.group.bright': 'Colores brillantes',
|
||||
'terminal.customTheme.color.background': 'Fondo',
|
||||
'terminal.customTheme.color.foreground': 'Primer plano',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selección',
|
||||
'terminal.customTheme.color.black': 'Negro',
|
||||
'terminal.customTheme.color.red': 'Rojo',
|
||||
'terminal.customTheme.color.green': 'Verde',
|
||||
'terminal.customTheme.color.yellow': 'Amarillo',
|
||||
'terminal.customTheme.color.blue': 'Azul',
|
||||
'terminal.customTheme.color.magenta': 'Magenta',
|
||||
'terminal.customTheme.color.cyan': 'Cian',
|
||||
'terminal.customTheme.color.white': 'Blanco',
|
||||
'terminal.customTheme.color.brightBlack': 'Negro brillante',
|
||||
'terminal.customTheme.color.brightRed': 'Rojo brillante',
|
||||
'terminal.customTheme.color.brightGreen': 'Verde brillante',
|
||||
'terminal.customTheme.color.brightYellow': 'Amarillo brillante',
|
||||
'terminal.customTheme.color.brightBlue': 'Azul brillante',
|
||||
'terminal.customTheme.color.brightMagenta': 'Magenta brillante',
|
||||
'terminal.customTheme.color.brightCyan': 'Cian brillante',
|
||||
'terminal.customTheme.color.brightWhite': 'Blanco brillante',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'Sincronización cifrada de extremo a extremo',
|
||||
'cloudSync.gate.desc':
|
||||
'Tus datos se cifran localmente antes de sincronizarse. Los proveedores de la nube nunca ven tus datos en texto plano. Configura una clave maestra para habilitar la sincronización segura.',
|
||||
'cloudSync.gate.masterKey': 'Clave maestra',
|
||||
'cloudSync.gate.confirmMasterKey': 'Confirmar clave maestra',
|
||||
'cloudSync.gate.placeholder': 'Ingresa una contraseña segura',
|
||||
'cloudSync.gate.confirmPlaceholder': 'Confirma tu contraseña',
|
||||
'cloudSync.gate.mismatch': 'Las contraseñas no coinciden',
|
||||
'cloudSync.gate.warning':
|
||||
'Entiendo que, si olvido mi clave maestra, mis datos no se podrán recuperar. No hay restablecimiento de contraseña.',
|
||||
'cloudSync.gate.enableVault': 'Habilitar bóveda cifrada',
|
||||
'cloudSync.gate.enabledToast': 'Bóveda cifrada habilitada',
|
||||
'cloudSync.gate.setupFailed': 'No se pudo configurar la clave maestra',
|
||||
'cloudSync.passwordStrength.tooShort': 'Demasiado corta',
|
||||
'cloudSync.passwordStrength.weak': 'Débil',
|
||||
'cloudSync.passwordStrength.moderate': 'Moderada',
|
||||
'cloudSync.passwordStrength.strong': 'Segura',
|
||||
'cloudSync.passwordStrength.veryStrong': 'Muy segura',
|
||||
'cloudSync.provider.notConnected': 'No conectado',
|
||||
'cloudSync.provider.sync': 'Sincronizar',
|
||||
'cloudSync.provider.connect': 'Conectar',
|
||||
'cloudSync.provider.connecting': 'Conectando...',
|
||||
'cloudSync.provider.disconnect': 'Desconectar',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '¿Desconectar "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. La bóveda local permanecerá en este equipo.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Desconectar',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
|
||||
'cloudSync.provider.s3': 'Compatible con S3',
|
||||
'cloudSync.provider.s3.desc': 'Conectar a almacenamiento de objetos compatible con S3',
|
||||
'cloudSync.provider.comingSoon': 'Próximamente',
|
||||
'cloudSync.webdav.title': 'Configuración de WebDAV',
|
||||
'cloudSync.webdav.desc': 'Configura un endpoint WebDAV para la sincronización cifrada.',
|
||||
'cloudSync.webdav.endpoint': 'URL del endpoint',
|
||||
'cloudSync.webdav.authType': 'Tipo de autenticación',
|
||||
'cloudSync.webdav.auth.basic': 'Básica',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
'cloudSync.webdav.auth.token': 'Token',
|
||||
'cloudSync.webdav.username': 'Nombre de usuario',
|
||||
'cloudSync.webdav.password': 'Contraseña',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': 'Mostrar secreto',
|
||||
'cloudSync.webdav.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Ingresa un endpoint WebDAV válido.',
|
||||
'cloudSync.webdav.validation.credentials': 'El nombre de usuario y la contraseña son obligatorios.',
|
||||
'cloudSync.webdav.validation.token': 'El token es obligatorio.',
|
||||
'cloudSync.s3.title': 'Configuración de S3',
|
||||
'cloudSync.s3.desc': 'Conéctate al almacenamiento de objetos compatible con S3 para la sincronización cifrada.',
|
||||
'cloudSync.s3.endpoint': 'URL del endpoint',
|
||||
'cloudSync.s3.region': 'Región',
|
||||
'cloudSync.s3.bucket': 'Bucket',
|
||||
'cloudSync.s3.accessKeyId': 'ID de clave de acceso',
|
||||
'cloudSync.s3.secretAccessKey': 'Clave de acceso secreta',
|
||||
'cloudSync.s3.sessionToken': 'Token de sesión (opcional)',
|
||||
'cloudSync.s3.prefix': 'Prefijo de clave (opcional)',
|
||||
'cloudSync.s3.forcePathStyle': 'Forzar URLs con estilo de ruta (para MinIO/R2, etc.)',
|
||||
'cloudSync.s3.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
|
||||
'cloudSync.s3.showSecret': 'Mostrar secretos',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, región, bucket, clave de acceso y secreto son obligatorios.',
|
||||
'cloudSync.smb.title': 'Configuración de SMB',
|
||||
'cloudSync.smb.desc': 'Conéctate a un recurso compartido de archivos SMB/CIFS para la sincronización cifrada.',
|
||||
'cloudSync.smb.share': 'Ruta del recurso compartido',
|
||||
'cloudSync.smb.username': 'Nombre de usuario',
|
||||
'cloudSync.smb.password': 'Contraseña',
|
||||
'cloudSync.smb.domain': 'Dominio (opcional)',
|
||||
'cloudSync.smb.domainPlaceholder': 'p. ej., WORKGROUP',
|
||||
'cloudSync.smb.port': 'Puerto (opcional)',
|
||||
'cloudSync.smb.showSecret': 'Mostrar contraseña',
|
||||
'cloudSync.smb.validation.share': 'La ruta del recurso compartido es obligatoria.',
|
||||
'cloudSync.smb.validation.port': 'El puerto debe ser un número entre 1 y 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB se conectó correctamente',
|
||||
'cloudSync.connect.smb.failedTitle': 'Falló la conexión a SMB',
|
||||
'cloudSync.provider.smb': 'Recurso compartido SMB',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV se conectó correctamente',
|
||||
'cloudSync.connect.webdav.failedTitle': 'Falló la conexión a WebDAV',
|
||||
'cloudSync.connect.s3.success': 'S3 se conectó correctamente',
|
||||
'cloudSync.connect.s3.failedTitle': 'Falló la conexión a S3',
|
||||
'cloudSync.connect.plugin.success': 'El proveedor de sincronización de plugin se conectó correctamente',
|
||||
'cloudSync.connect.plugin.failedTitle': 'Falló la conexión del plugin de sincronización',
|
||||
'cloudSync.pluginConfig.title': 'Configurar {name}',
|
||||
'cloudSync.pluginConfig.desc': 'Ingresa la configuración JSON requerida por este proveedor de sincronización de plugin.',
|
||||
'cloudSync.pluginConfig.label': 'Configuración del proveedor (JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': 'La configuración debe ser JSON válido.',
|
||||
'cloudSync.pluginConfig.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
|
||||
'cloudSync.lastSync.never': 'Nunca',
|
||||
'cloudSync.lastSync.justNow': 'Justo ahora',
|
||||
'cloudSync.lastSync.minutesAgo': 'Hace {minutes} min',
|
||||
'cloudSync.changeKey': 'Cambiar clave',
|
||||
'cloudSync.providers.title': 'Proveedores de nube',
|
||||
'cloudSync.syncAll': 'Sincronizar todos los proveedores conectados',
|
||||
'cloudSync.autoSync.title': 'Sincronización automática',
|
||||
'cloudSync.autoSync.desc': 'Sincronizar automáticamente cuando se realicen cambios',
|
||||
'cloudSync.strategy.title': 'Estrategia de sincronización',
|
||||
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
|
||||
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si NetMesh no puede decidir con seguridad, te pedirá que elijas.',
|
||||
'cloudSync.strategy.preferCloud': 'Gana la nube',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
|
||||
'cloudSync.strategy.preferLocal': 'Gana lo local',
|
||||
'cloudSync.strategy.preferLocalDesc': 'Cuando ambos lados cambiaron, sube la versión local y reemplaza los cambios de la nube.',
|
||||
'cloudSync.convergent.title': 'Sincronización convergente de múltiples dispositivos',
|
||||
'cloudSync.convergent.experimental': 'Experimental',
|
||||
'cloudSync.convergent.desc': 'Usa una réplica CRDT cifrada para preservar ediciones sin conexión, eliminaciones concurrentes y cambios de cada proveedor conectado.',
|
||||
'cloudSync.convergent.active': 'CRDT v2 está activo. Las escrituras del proveedor se verifican después de la subida.',
|
||||
'cloudSync.convergent.paused': 'CRDT v2 está en pausa en este dispositivo; los metadatos de la nube se conservan.',
|
||||
'cloudSync.convergent.enabled': 'Sincronización convergente habilitada',
|
||||
'cloudSync.convergent.preview.title': 'Vista previa de migración',
|
||||
'cloudSync.convergent.preview.entities': 'Entidades',
|
||||
'cloudSync.convergent.preview.providers': 'Proveedores',
|
||||
'cloudSync.convergent.preview.conflicts': 'Conflictos',
|
||||
'cloudSync.convergent.preview.compatibility': 'Una instantánea v1 completa permanece en cada payload cifrado para clientes más antiguos.',
|
||||
'cloudSync.convergent.preview.confirm': 'Crear réplica CRDT',
|
||||
'cloudSync.convergent.preview.status.ready': 'Listo',
|
||||
'cloudSync.convergent.preview.status.empty': 'Vacío',
|
||||
'cloudSync.convergent.preview.status.unavailable': 'No disponible',
|
||||
'cloudSync.convergent.preview.status.blocked': 'Bloqueado',
|
||||
'cloudSync.convergent.preview.schema': 'esquema',
|
||||
'cloudSync.convergent.field.presence': 'presencia',
|
||||
'cloudSync.convergent.field.position': 'posición',
|
||||
'cloudSync.convergent.conflicts.title': 'Conflictos de campo ({count})',
|
||||
'cloudSync.convergent.conflict.empty': 'Vacío / eliminado',
|
||||
'cloudSync.convergent.conflict.secretSet': 'El secreto está establecido',
|
||||
'cloudSync.convergent.conflict.current': 'ganador actual',
|
||||
'cloudSync.convergent.conflict.choose': 'Elegir',
|
||||
'cloudSync.convergent.conflict.resolved': 'Conflicto resuelto y sincronizado',
|
||||
'cloudSync.convergent.downgrade.desc': 'Reemplaza los archivos v2 de cada proveedor conectado con una instantánea heredada.',
|
||||
'cloudSync.convergent.downgrade.button': 'Degradar',
|
||||
'cloudSync.convergent.downgrade.confirm': '¿Degradar cada proveedor conectado a la sincronización heredada? Esto elimina los metadatos CRDT después de la verificación de escritura.',
|
||||
'cloudSync.convergent.downgrade.done': 'Sincronización convergente degradada',
|
||||
'cloudSync.status.title': 'Estado de sincronización',
|
||||
'cloudSync.status.localVersion': 'Versión local',
|
||||
'cloudSync.status.remoteVersion': 'Versión remota',
|
||||
'cloudSync.history.title': 'Historial de sincronización',
|
||||
'cloudSync.history.upload': 'Subida',
|
||||
'cloudSync.history.download': 'Descarga',
|
||||
'cloudSync.history.resolved': 'Resuelto',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||
'cloudSync.localBackups.desc': 'NetMesh conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar NetMesh.',
|
||||
'cloudSync.localBackups.maxCount': 'Copias máximas',
|
||||
'cloudSync.localBackups.maxSaved': 'Retención de copia guardada: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Ingresa un número entre 1 y 100.',
|
||||
'cloudSync.localBackups.empty': 'Aún no hay copias de seguridad locales.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Antes del cambio de versión de la app',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Antes de la restauración',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} claves, {snippets} snippets, {notes} notas',
|
||||
'cloudSync.localBackups.restore': 'Restaurar',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
|
||||
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Restaurar',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Cancelar',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Copias de seguridad locales no disponibles',
|
||||
'cloudSync.localBackups.unavailableDesc': 'Esta plataforma no expone un keychain seguro a NetMesh, por lo que las copias de seguridad locales no se pueden escribir de forma segura. Instala NetMesh en un sistema con un keychain compatible para habilitar el historial de copias de seguridad locales.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Se requiere clave maestra',
|
||||
'cloudSync.localBackups.lockedDesc': 'Configura o desbloquea tu clave maestra antes de restaurar una copia de seguridad, para que las credenciales restauradas permanezcan cifradas.',
|
||||
'cloudSync.revisionHistory.viewButton': 'Historial',
|
||||
'cloudSync.revisionHistory.title': 'Historial de versiones de la bóveda',
|
||||
'cloudSync.revisionHistory.description': 'Explora y restaura versiones anteriores de tu bóveda desde el historial de revisiones de Gist.',
|
||||
'cloudSync.revisionHistory.empty': 'No se encontraron revisiones.',
|
||||
'cloudSync.revisionHistory.current': 'Actual',
|
||||
'cloudSync.revisionHistory.revision': 'Revisión',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Contenido de la revisión',
|
||||
'cloudSync.revisionHistory.device': 'Dispositivo',
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Claves',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.notes': 'Notas',
|
||||
'cloudSync.revisionHistory.identities': 'Identidades',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restaurar esta versión',
|
||||
'cloudSync.revisionHistory.restored': 'Bóveda restaurada desde la revisión seleccionada.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Revisión no encontrada o no contiene datos de la bóveda.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'No se puede descifrar esta revisión. Puede que se haya cifrado con una contraseña maestra diferente.',
|
||||
'cloudSync.changeKey.title': 'Cambiar clave maestra',
|
||||
'cloudSync.changeKey.current': 'Clave maestra actual',
|
||||
'cloudSync.changeKey.new': 'Nueva clave maestra',
|
||||
'cloudSync.changeKey.confirmNew': 'Confirmar nueva clave maestra',
|
||||
'cloudSync.changeKey.currentPlaceholder': 'Ingresa la clave maestra actual',
|
||||
'cloudSync.changeKey.newPlaceholder': 'Ingresa la nueva clave maestra',
|
||||
'cloudSync.changeKey.confirmPlaceholder': 'Confirma la nueva clave maestra',
|
||||
'cloudSync.changeKey.fillAll': 'Completa todos los campos',
|
||||
'cloudSync.changeKey.minLength': 'La nueva clave maestra debe tener al menos 8 caracteres',
|
||||
'cloudSync.changeKey.notMatch': 'Las nuevas claves maestras no coinciden',
|
||||
'cloudSync.changeKey.incorrectCurrent': 'Clave maestra actual incorrecta',
|
||||
'cloudSync.changeKey.failed': 'No se pudo cambiar la clave maestra',
|
||||
'cloudSync.changeKey.desc': 'Esto volverá a cifrar tu bóveda. Asegúrate de recordar la nueva clave.',
|
||||
'cloudSync.changeKey.showKeys': 'Mostrar claves',
|
||||
'cloudSync.changeKey.updatedToast': 'Clave maestra actualizada',
|
||||
'cloudSync.changeKey.updateButton': 'Actualizar clave',
|
||||
'cloudSync.unlock.title': 'Ingresa la clave maestra',
|
||||
'cloudSync.unlock.masterKey': 'Clave maestra',
|
||||
'cloudSync.unlock.desc':
|
||||
'Ingresa tu clave maestra una vez para habilitar la sincronización cifrada. Se almacenará de forma segura usando el keychain de tu sistema operativo.',
|
||||
'cloudSync.unlock.placeholder': 'Ingresa tu clave maestra',
|
||||
'cloudSync.unlock.empty': 'Ingresa tu clave maestra',
|
||||
'cloudSync.unlock.incorrect': 'Clave maestra incorrecta',
|
||||
'cloudSync.unlock.failed': 'No se pudo desbloquear la bóveda',
|
||||
'cloudSync.unlock.showKey': 'Mostrar clave',
|
||||
'cloudSync.unlock.notNow': 'Ahora no',
|
||||
'cloudSync.unlock.readyToast': 'Bóveda lista',
|
||||
'cloudSync.unlock.unlockButton': 'Desbloquear',
|
||||
'cloudSync.header.vaultReady': 'Bóveda lista',
|
||||
'cloudSync.header.preparingVault': 'Preparando bóveda...',
|
||||
'cloudSync.header.providersConnected': '{count} proveedor(es) conectado(s)',
|
||||
'cloudSync.githubFlow.title': 'Conectar con GitHub',
|
||||
'cloudSync.githubFlow.desc': 'Copia el código de abajo e ingrésalo en GitHub para autorizar a NetMesh.',
|
||||
'cloudSync.githubFlow.copyCode': 'Copiar código',
|
||||
'cloudSync.githubFlow.copied': '¡Copiado!',
|
||||
'cloudSync.githubFlow.openGitHub': 'Abrir GitHub',
|
||||
'cloudSync.githubFlow.waiting': 'Esperando autorización...',
|
||||
'cloudSync.conflict.title': 'Se detectó un conflicto de versiones',
|
||||
'cloudSync.conflict.desc': 'Elige qué versión conservar',
|
||||
'cloudSync.conflict.local': 'LOCAL',
|
||||
'cloudSync.conflict.cloud': 'NUBE',
|
||||
'cloudSync.conflict.detailsTitle': 'Datos cambiados',
|
||||
'cloudSync.conflict.detailsCounts': 'Local {local} · Nube {cloud} · Conflictos {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': 'Hosts',
|
||||
'cloudSync.conflict.entity.keys': 'Claves',
|
||||
'cloudSync.conflict.entity.identities': 'Identidades',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Perfiles de proxy',
|
||||
'cloudSync.conflict.entity.snippets': 'Snippets',
|
||||
'cloudSync.conflict.entity.notes': 'Notas',
|
||||
'cloudSync.conflict.entity.noteGroups': 'Grupos de notas',
|
||||
'cloudSync.conflict.entity.customGroups': 'Grupos',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
|
||||
'cloudSync.conflict.entity.settings': 'Configuración',
|
||||
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
|
||||
'cloudSync.conflict.useCloud': 'Descargar la nube (sobrescribir local)',
|
||||
'cloudSync.connect.browserContinue': 'Completar la autorización en el navegador',
|
||||
'cloudSync.connect.browserCancelled': 'La autorización anterior del navegador fue cancelada',
|
||||
'cloudSync.connect.github.success': 'GitHub se conectó correctamente',
|
||||
'cloudSync.connect.github.failedTitle': 'Falló la conexión a GitHub',
|
||||
'cloudSync.connect.github.timeout': 'Se agotó el tiempo de espera de la conexión a GitHub. Verifica tu red o la configuración del proxy.',
|
||||
'cloudSync.connect.github.networkError': 'No se puede acceder a GitHub. Verifica tu red o la configuración del proxy.',
|
||||
'cloudSync.connect.google.failedTitle': 'Falló la conexión a Google',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'Falló la conexión a OneDrive',
|
||||
'cloudSync.sync.success': 'Sincronizado con {provider}',
|
||||
'cloudSync.sync.failed': 'Falló la sincronización',
|
||||
'cloudSync.sync.failedTitle': 'Falló la sincronización',
|
||||
'cloudSync.sync.errorTitle': 'Error de sincronización',
|
||||
'cloudSync.resolve.downloaded': 'Datos de la nube descargados',
|
||||
'cloudSync.resolve.uploaded': 'Datos locales subidos',
|
||||
'cloudSync.resolve.failedTitle': 'Falló la resolución del conflicto',
|
||||
'cloudSync.clearLocal.title': 'Borrar datos locales',
|
||||
'cloudSync.clearLocal.desc': 'Restablece la versión local y el historial de sincronización. La próxima sincronización descargará desde la nube.',
|
||||
'cloudSync.clearLocal.button': 'Borrar',
|
||||
'cloudSync.clearLocal.dialog.title': '¿Borrar los datos locales de la bóveda?',
|
||||
'cloudSync.clearLocal.dialog.desc': 'Esto restablecerá la versión local a 0 y borrará el historial de sincronización. Tu próxima sincronización descargará los datos de la nube, reemplazando los datos locales.',
|
||||
'cloudSync.clearLocal.dialog.cancel': 'Cancelar',
|
||||
'cloudSync.clearLocal.dialog.confirm': 'Borrar datos locales',
|
||||
'cloudSync.clearLocal.toast.title': 'Datos locales borrados',
|
||||
'cloudSync.clearLocal.toast.desc': 'Versión local restablecida a 0. Sincroniza para descargar desde la nube.',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': 'CLAVE',
|
||||
'keychain.filter.certificate': 'CERTIFICADO',
|
||||
'keychain.action.generateKey': 'Generar clave',
|
||||
'keychain.action.importKey': 'Importar clave',
|
||||
'keychain.action.newIdentity': 'Nueva identidad',
|
||||
'keychain.action.importCertificate': 'Importar certificado',
|
||||
'keychain.view.grid': 'Cuadrícula',
|
||||
'keychain.view.list': 'Lista',
|
||||
'keychain.section.keys': 'Claves',
|
||||
'keychain.section.identities': 'Identidades',
|
||||
'keychain.count.items': '{count} elementos',
|
||||
'keychain.empty.title': 'Configura tus claves',
|
||||
'keychain.empty.desc': 'Importa o genera claves SSH para una autenticación segura.',
|
||||
'keychain.panel.generateKey': 'Generar clave',
|
||||
'keychain.panel.newKey': 'Nueva clave',
|
||||
'keychain.panel.keyDetails': 'Detalles de la clave',
|
||||
'keychain.panel.editKey': 'Editar clave',
|
||||
'keychain.panel.editIdentity': 'Editar identidad',
|
||||
'keychain.panel.newIdentity': 'Nueva identidad',
|
||||
'keychain.panel.keyExport': 'Exportación de clave',
|
||||
'keychain.validation.labelRequired': 'Ingresa una etiqueta para la clave',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'La etiqueta y la clave privada son obligatorias',
|
||||
'keychain.validation.labelAndUsernameRequired': 'La etiqueta y el nombre de usuario son obligatorios',
|
||||
'keychain.error.generationUnavailable':
|
||||
'La generación de claves no está disponible: asegúrate de que la app se esté ejecutando en Electron',
|
||||
'keychain.error.generateKeyPairFailed': 'No se pudo generar el par de claves',
|
||||
'keychain.error.generateKeyFailed': 'No se pudo generar la clave',
|
||||
'keychain.error.keyGenerationTitle': 'Generación de claves',
|
||||
'keychain.export.exportTo': 'Exportar a *',
|
||||
'keychain.export.selectHost': 'Seleccionar host',
|
||||
'keychain.export.location': 'Ubicación ~ $1 *',
|
||||
'keychain.export.filename': 'Nombre de archivo ~ $2 *',
|
||||
'keychain.export.note':
|
||||
'La exportación de claves actualmente solo admite sistemas {unix}. Usa la sección {advanced} para personalizar el script de exportación.',
|
||||
'keychain.export.script': 'Script *',
|
||||
'keychain.export.scriptPlaceholder': 'Script de exportación...',
|
||||
'keychain.export.missingCredentials':
|
||||
'El host no tiene contraseña ni clave guardadas. Primero agrega credenciales de contraseña al host.',
|
||||
'keychain.export.successTitle': 'Exportación exitosa',
|
||||
'keychain.export.successMessage': 'Clave pública exportada y adjuntada a {host}',
|
||||
'keychain.export.failedTitle': 'Falló la exportación',
|
||||
'keychain.export.failedMessage': 'No se pudo exportar la clave: {error}',
|
||||
'keychain.export.failedPrefix': 'Falló la exportación: {error}',
|
||||
'keychain.export.exitCode': 'El comando terminó con el código {code}',
|
||||
'keychain.export.exporting': 'Exportando...',
|
||||
'keychain.export.exportAndAttach': 'Exportar y adjuntar',
|
||||
'keychain.export.title': 'Exportación de clave',
|
||||
'keychain.export.exportToRequired': 'Exportar a *',
|
||||
'keychain.export.selectHostPlaceholder': 'Selecciona un host...',
|
||||
'keychain.export.locationLabel': 'Ubicación ~ $1 *',
|
||||
'keychain.export.filenameLabel': 'Nombre de archivo ~ $2 *',
|
||||
'keychain.export.advanced': 'Avanzado',
|
||||
'keychain.export.note.supportsOnly': 'La exportación de claves actualmente solo admite',
|
||||
'keychain.export.note.systems': 'sistemas.',
|
||||
'keychain.export.note.use': 'Usa la',
|
||||
'keychain.export.note.customize': 'sección para personalizar el script de exportación.',
|
||||
'keychain.export.scriptRequired': 'Script *',
|
||||
'keychain.export.exportToHost': 'Exportar al host',
|
||||
'keychain.export.failedGeneric': 'Falló la exportación: {message}',
|
||||
'keychain.field.label': 'Etiqueta',
|
||||
'keychain.field.labelRequired': 'Etiqueta *',
|
||||
'keychain.field.labelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.field.privateKeyRequired': 'Clave privada *',
|
||||
'keychain.field.publicKey': 'Clave pública',
|
||||
'keychain.field.certificatePlaceholder': 'Contenido del certificado (opcional)',
|
||||
'keychain.generate.keyType': 'Tipo de clave',
|
||||
'keychain.generate.keySize': 'Tamaño de clave',
|
||||
'keychain.generate.labelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.generate.passphrasePlaceholder': 'Frase de contraseña (opcional)',
|
||||
'keychain.generate.savePassphrase': 'Guardar frase de contraseña',
|
||||
'keychain.generate.generate': 'Generar',
|
||||
'keychain.generate.generateSave': 'Generar y guardar',
|
||||
'keychain.import.dropHint': 'Suelta un archivo de clave aquí',
|
||||
'keychain.import.importFromFile': 'Importar desde archivo',
|
||||
'keychain.import.saveKey': 'Guardar clave',
|
||||
'keychain.import.importedKeyLabel': 'Clave importada',
|
||||
'keychain.identity.usernameRequired': 'Nombre de usuario *',
|
||||
'keychain.identity.method.passwordOnly': 'Contraseña',
|
||||
'keychain.identity.summary.password': 'Contraseña de autenticación',
|
||||
'keychain.identity.summary.key': 'Clave de autenticación',
|
||||
'keychain.identity.summary.certificate': 'Certificado de autenticación',
|
||||
'keychain.identity.summary.passwordAndKey': 'Contraseña y clave de autenticación',
|
||||
'keychain.identity.summary.passwordAndCertificate': 'Contraseña y certificado de autenticación',
|
||||
'keychain.identity.summary.none': 'Sin credenciales',
|
||||
'keychain.identity.selectCredential': 'Seleccionar {kind}',
|
||||
'keychain.identity.save': 'Guardar',
|
||||
'keychain.identity.update': 'Actualizar',
|
||||
'keychain.keyDialog.newTitle': 'Nueva clave',
|
||||
'keychain.keyDialog.newDesc': 'Agregar una nueva clave SSH',
|
||||
'keychain.keyDialog.editTitle': 'Editar clave',
|
||||
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
|
||||
'keychain.keyDialog.updateKey': 'Actualizar clave',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Cerrar sesión',
|
||||
'tabs.closeLogViewAria': 'Cerrar vista de registro',
|
||||
'tabs.closePluginViewAria': 'Cerrar {title}',
|
||||
'tabs.logPrefix': 'Registro:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copiar pestaña',
|
||||
'tabs.duplicateSession': 'Duplicar sesión',
|
||||
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
|
||||
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
|
||||
'tabs.closeOthers': 'Cerrar las demás',
|
||||
'tabs.closeToRight': 'Cerrar pestañas a la derecha',
|
||||
'tabs.closeAll': 'Cerrar todas',
|
||||
'keychain.edit.labelRequired': 'Etiqueta *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.edit.privateKeyRequired': 'Clave privada *',
|
||||
'keychain.edit.publicKey': 'Clave pública',
|
||||
'keychain.edit.certificate': 'Certificado',
|
||||
'keychain.edit.certificatePlaceholder': 'Contenido del certificado (opcional)',
|
||||
'keychain.edit.filePath': 'Ruta del archivo',
|
||||
'keychain.edit.keyExport': 'Exportación de clave',
|
||||
'keychain.edit.exportToHost': 'Exportar al host',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Buscar scripts...',
|
||||
'snippets.action.newSnippet': 'Nuevo snippet',
|
||||
'snippets.action.newPackage': 'Nuevo paquete de scripts',
|
||||
'snippets.action.import': 'Importar',
|
||||
'snippets.action.selectSnippets': 'Seleccionar snippets',
|
||||
'snippets.panel.newTitle': 'Nuevo snippet',
|
||||
'snippets.panel.editTitle': 'Editar snippet',
|
||||
'snippets.panel.newAutomationTitle': 'Nuevo script de automatización',
|
||||
'snippets.panel.editAutomationTitle': 'Editar script de automatización',
|
||||
'snippets.panel.resizeWidth': 'Cambiar el ancho del panel',
|
||||
'snippets.field.description': 'Descripción de la acción',
|
||||
'snippets.field.descriptionPlaceholder': 'Ejemplo: verificar la carga de la red',
|
||||
'snippets.field.package': 'Agregar un paquete de scripts',
|
||||
'snippets.field.packagePlaceholder': 'Selecciona o crea un paquete de scripts',
|
||||
'snippets.field.createPackage': 'Crear paquete de scripts',
|
||||
'snippets.field.scriptRequired': 'Script *',
|
||||
'snippets.scriptEditor.expand': 'Abrir en diálogo',
|
||||
'snippets.scriptEditor.resize': 'Cambiar la altura del editor',
|
||||
'snippets.scriptEditor.modalTitle': 'Editar script',
|
||||
'snippets.targets.title': 'Destinos',
|
||||
'snippets.targets.add': 'Agregar destinos',
|
||||
'snippets.targets.selectHosts': 'Hosts',
|
||||
'snippets.targets.selectGroups': 'Grupos',
|
||||
'snippets.targets.noGroups': 'No se encontraron grupos',
|
||||
'snippets.targets.allHosts': 'Aplicar a todos los hosts',
|
||||
'snippets.targets.allHostsShort': 'Todos los hosts',
|
||||
'snippets.targets.allHostsActive': 'Se aplica a cada host conectable.',
|
||||
'snippets.history.title': 'Historial de Shell',
|
||||
'snippets.history.subtitle': '{count} comandos',
|
||||
'snippets.history.emptyTitle': 'Aún no hay historial de Shell',
|
||||
'snippets.history.emptyDesc': 'Los comandos que ejecutes aparecerán aquí',
|
||||
'snippets.history.loadMore': 'Cargar más',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': 'Establece una etiqueta para este snippet',
|
||||
'snippets.history.saveAsSnippet': 'Guardar como snippet',
|
||||
'snippets.history.time.justNow': 'justo ahora',
|
||||
'snippets.history.time.minutesAgo': 'hace {count}m',
|
||||
'snippets.history.time.hoursAgo': 'hace {count}h',
|
||||
'snippets.history.time.daysAgo': 'hace {count}d',
|
||||
'snippets.breadcrumb.allPackages': 'Todos los paquetes de scripts',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Crea scripts',
|
||||
'snippets.empty.desc': 'Guarda comandos comunes como snippets de código, o escribe scripts de automatización para operaciones repetibles.',
|
||||
'snippets.search.noResults.title': 'Sin coincidencias',
|
||||
'snippets.search.noResults.desc': 'Ningún script o paquete de scripts coincide con "{query}". Prueba con otro término de búsqueda o limpia la búsqueda para explorar.',
|
||||
'snippets.section.packages': 'Paquetes de scripts',
|
||||
'snippets.section.snippets': 'Scripts',
|
||||
'snippets.kind.codeSnippet': 'Snippet de código',
|
||||
'snippets.kind.automationScript': 'Script de automatización',
|
||||
'snippets.package.count': '{count} script(s)',
|
||||
'snippets.commandFallback': 'Comando',
|
||||
'snippets.view.grid': 'Cuadrícula',
|
||||
'snippets.view.list': 'Lista',
|
||||
'snippets.selection.selected': '{count} seleccionados',
|
||||
'snippets.selection.selectVisible': 'Seleccionar visibles',
|
||||
'snippets.selection.deselectAll': 'Deseleccionar todo',
|
||||
'snippets.selection.exportSelected': 'Exportar seleccionados ({count})',
|
||||
'snippets.selection.deleteSelected': 'Eliminar ({count})',
|
||||
'snippets.selection.deleteConfirmTitle': '¿Eliminar los elementos seleccionados ({count})?',
|
||||
'snippets.selection.deleteConfirmDesc': 'Los elementos seleccionados se eliminarán de forma permanente. Esta acción no se puede deshacer.',
|
||||
'snippets.selection.deleteSuccess': 'Elementos seleccionados eliminados: {count}.',
|
||||
'snippets.export.snippet': 'Exportar snippet',
|
||||
'snippets.export.package': 'Exportar paquete de scripts',
|
||||
'snippets.export.toast.empty': 'No hay snippets para exportar.',
|
||||
'snippets.export.toast.successTitle': 'Exportación lista',
|
||||
'snippets.export.toast.success': 'Se exportaron {count} snippet(s).',
|
||||
'snippets.import.toast.empty': 'No se encontraron snippets importables.',
|
||||
'snippets.import.toast.failedTitle': 'Falló la importación',
|
||||
'snippets.import.toast.invalidDesc': 'Este no es un archivo de snippets válido de NetMesh.',
|
||||
'snippets.import.toast.successTitle': 'Importación completada',
|
||||
'snippets.import.toast.summary': 'Importados {imported}, sobrescritos {overwritten}, omitidos {skipped}.',
|
||||
'snippets.import.modal.title': 'Importar snippets',
|
||||
'snippets.import.modal.desc': 'Elige uno o más archivos JSON de snippets de NetMesh. NetMesh los mostrará en vista previa antes de importar cualquier cosa.',
|
||||
'snippets.import.modal.exampleTitle': 'Ejemplo de JSON',
|
||||
'snippets.import.modal.noFile': 'Aún no se seleccionó ningún archivo. Usa el formato de ejemplo de abajo, o un arreglo JSON simple de snippets, y luego elige uno o más archivos.',
|
||||
'snippets.import.modal.chooseFile': 'Elegir archivo',
|
||||
'snippets.import.modal.downloadExamples': 'Descargar ejemplos',
|
||||
'snippets.import.modal.multipleFiles': '{count} archivos seleccionados',
|
||||
'snippets.import.modal.parsedSummary': '{files} archivo(s), {total} script(s), {packages} paquete(s) de scripts, {conflicts} comando(s) duplicado(s). Las vinculaciones de host se ignoran.',
|
||||
'snippets.import.modal.confirm': 'Confirmar importación',
|
||||
'snippets.import.conflict.title': '¿Importar snippets?',
|
||||
'snippets.import.conflict.desc': '{file} contiene {total} snippet(s). Ya existen {conflicts} comando(s) duplicado(s).',
|
||||
'snippets.import.conflict.hostBindingsNote': 'Las vinculaciones de host no se importan ni se exportan con los snippets.',
|
||||
'snippets.import.conflict.skip': 'Omitir duplicados',
|
||||
'snippets.import.conflict.overwrite': 'Sobrescribir duplicados',
|
||||
'snippets.packageDialog.title': 'Nuevo paquete de scripts',
|
||||
'snippets.packageDialog.parent': 'Padre: {parent}',
|
||||
'snippets.packageDialog.root': 'Raíz',
|
||||
'snippets.packageDialog.placeholder': 'p. ej., ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Usa "/" para crear paquetes de scripts anidados.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Renombrar paquete de scripts',
|
||||
'snippets.renameDialog.currentPath': 'Ruta actual: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Ingresa el nuevo nombre',
|
||||
'snippets.renameDialog.error.empty': 'El nombre del paquete de scripts no puede estar vacío',
|
||||
'snippets.renameDialog.error.duplicate': 'Ya existe un paquete de scripts con este nombre',
|
||||
'snippets.renameDialog.error.invalidChars': 'El nombre del paquete de scripts solo puede contener letras, números, guiones y guiones bajos',
|
||||
|
||||
'snippets.field.noAutoRun': 'Solo pegar (no auto-ejecutar)',
|
||||
'snippets.field.multiLineRunMode': 'Ejecución multilínea',
|
||||
'snippets.field.multiLineRunMode.paste': 'Enviar todo de una vez',
|
||||
'snippets.field.multiLineRunMode.lineDelay': 'Enviar línea por línea',
|
||||
'snippets.field.multiLineRunModeHint': 'Usa línea por línea para inicios de sesión que piden credenciales (prompt) o macros de dispositivos.',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Atajo de teclado',
|
||||
'snippets.shortkey.placeholder': 'Haz clic para configurar el atajo',
|
||||
'snippets.shortkey.recording': 'Presiona una combinación de teclas...',
|
||||
'snippets.shortkey.hint': 'Presiona este atajo en la terminal para enviar el comando rápidamente.',
|
||||
'snippets.shortkey.clear': 'Borrar atajo',
|
||||
'snippets.shortkey.error.systemConflict': 'Este atajo entra en conflicto con {name}',
|
||||
'snippets.shortkey.error.snippetConflict': 'Este atajo ya lo usa el snippet: {name}',
|
||||
|
||||
'snippets.variables.dialogTitle': 'Variables del snippet',
|
||||
'snippets.variables.dialogDesc': 'Completa los valores de "{label}" antes de ejecutar.',
|
||||
'snippets.variables.hint': 'Los valores se insertan tal cual en el script (sin escapado de shell).',
|
||||
'snippets.variables.preview': 'Vista previa',
|
||||
'snippets.variables.placeholder': 'Ingresa un valor',
|
||||
'snippets.variables.placeholderDefault': 'Predeterminado: {value}',
|
||||
'snippets.variables.required': 'Esta variable es obligatoria',
|
||||
'snippets.variables.run': 'Ejecutar',
|
||||
'snippets.field.variablesHelp': 'Usa {{name}} o {{name:default}} para los marcadores de posición en el script.',
|
||||
'snippets.field.variablesDetected': 'Variables',
|
||||
'snippets.field.variableDefault': 'predeterminado {value}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Conectar a puerto serial',
|
||||
'serial.modal.desc': 'Configura los ajustes de conexión del puerto serial',
|
||||
'serial.field.port': 'Puerto serial',
|
||||
'serial.field.selectPort': 'Selecciona un puerto...',
|
||||
'serial.field.baudRate': 'Velocidad de baudios',
|
||||
'serial.field.dataBits': 'Bits de datos',
|
||||
'serial.field.stopBits': 'Bits de parada',
|
||||
'serial.field.stopBits15Warning': 'Los 1.5 bits de parada pueden no ser compatibles con todos los dispositivos Windows',
|
||||
'serial.field.parity': 'Paridad',
|
||||
'serial.field.flowControl': 'Control de flujo',
|
||||
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
|
||||
'serial.field.customPort': 'Ruta de puerto personalizada',
|
||||
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudoterminal',
|
||||
'serial.type.custom': 'Personalizado',
|
||||
'serial.parity.none': 'Ninguna',
|
||||
'serial.parity.even': 'Par',
|
||||
'serial.parity.odd': 'Impar',
|
||||
'serial.parity.mark': 'Marca',
|
||||
'serial.parity.space': 'Espacio',
|
||||
'serial.flowControl.none': 'Ninguno',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
|
||||
'serial.field.localEcho': 'Forzar eco local',
|
||||
'serial.field.localEchoDesc': 'Hacer eco de los caracteres escritos localmente (para dispositivos sin eco remoto)',
|
||||
'serial.field.lineMode': 'Modo de línea',
|
||||
'serial.field.lineModeDesc': 'Almacenar en búfer la entrada y enviarla al presionar Enter (en lugar de carácter por carácter)',
|
||||
'serial.field.backspaceBehavior': 'Tecla de retroceso',
|
||||
'serial.field.backspaceBehaviorDesc': 'Usa Ctrl+H para dispositivos de red que no responden al código de Retroceso predeterminado.',
|
||||
'serial.backspace.default': 'Predeterminado (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Codificación de caracteres',
|
||||
'serial.connectionError': 'No se pudo conectar al puerto serial',
|
||||
'serial.field.baudRatePlaceholder': 'Selecciona o ingresa la velocidad de baudios...',
|
||||
'serial.field.baudRateEmpty': 'Ingresa una velocidad de baudios personalizada',
|
||||
'serial.field.customBaudRate': 'Usando velocidad de baudios personalizada',
|
||||
'serial.field.saveConfig': 'Guardar configuración',
|
||||
'serial.field.saveConfigDesc': 'Guarda esta configuración serial en hosts para acceso rápido',
|
||||
'serial.field.configLabel': 'Nombre de configuración',
|
||||
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
|
||||
'serial.connectAndSave': 'Conectar y guardar',
|
||||
'serial.edit.title': 'Configuración del puerto serial',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Se requiere autenticación',
|
||||
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
|
||||
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
|
||||
'keyboard.interactive.response': 'Respuesta',
|
||||
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
|
||||
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
|
||||
'keyboard.interactive.submit': 'Enviar',
|
||||
'keyboard.interactive.verifying': 'Verificando...',
|
||||
'keyboard.interactive.savePassword': 'Guardar contraseña',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Frase de contraseña de la clave SSH',
|
||||
'passphrase.desc': 'Ingresa la frase de contraseña de {keyName}',
|
||||
'passphrase.descWithHost': 'Ingresa la frase de contraseña de {keyName} para conectarte a {hostname}',
|
||||
'passphrase.label': 'Frase de contraseña',
|
||||
'passphrase.keyPath': 'Clave',
|
||||
'passphrase.unlock': 'Desbloquear',
|
||||
'passphrase.unlocking': 'Desbloqueando...',
|
||||
'passphrase.skip': 'Omitir',
|
||||
'passphrase.remember': 'Recordar esta frase de contraseña',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Ajuste de línea',
|
||||
'sftp.editor.maximize': 'Maximizar',
|
||||
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
|
||||
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
|
||||
'sftp.editor.discardChanges': 'Descartar',
|
||||
'sftp.editor.saveAndClose': 'Guardar y cerrar',
|
||||
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
|
||||
|
||||
};
|
||||
1085
application/i18n/locales/es/vault.ts
Normal file
1085
application/i18n/locales/es/vault.ts
Normal file
File diff suppressed because it is too large
Load Diff
20
application/i18n/locales/ru.ts
Normal file
20
application/i18n/locales/ru.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Messages } from './types';
|
||||
import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
import { ruTerminalMessages } from './ru/terminal';
|
||||
import { ruAiMessages } from './ru/ai';
|
||||
import { ruSystemManagerMessages } from './ru/systemManager';
|
||||
import { ruScriptsMessages } from './ru/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
const ru: Messages = {
|
||||
...ruCoreMessages,
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
...ruAiMessages,
|
||||
...ruSystemManagerMessages,
|
||||
...ruScriptsMessages,
|
||||
};
|
||||
|
||||
export default ru;
|
||||
617
application/i18n/locales/ru/ai.ts
Normal file
617
application/i18n/locales/ru/ai.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruAiMessages: Messages = {
|
||||
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
'ai.agents': 'Агенты',
|
||||
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
|
||||
'ai.providers.add': 'Добавить провайдера',
|
||||
'ai.providers.active': 'Активен',
|
||||
'ai.providers.apiKeyConfigured': 'API-ключ настроен',
|
||||
'ai.providers.noApiKey': 'Нет API-ключа',
|
||||
'ai.providers.configure': 'Настроить',
|
||||
'ai.providers.remove': 'Удалить',
|
||||
'ai.providers.name': 'Отображаемое имя',
|
||||
'ai.providers.name.placeholder': 'например, Мой провайдер',
|
||||
'ai.providers.style': 'Стиль протокола',
|
||||
'ai.providers.style.anthropic': 'Совместимый с Anthropic',
|
||||
'ai.providers.style.openai': 'Совместимый с OpenAI',
|
||||
'ai.providers.style.google': 'Совместимый с Google',
|
||||
'ai.providers.style.inherited': 'авто',
|
||||
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
|
||||
'ai.providers.openaiApi': 'Формат API OpenAI',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': 'Chat Completions работает с большинством OpenAI-совместимых эндпоинтов. Responses может повысить попадание в кэш на ретрансляторах с поддержкой /v1/responses.',
|
||||
'ai.providers.icon.change': 'Изменить иконку',
|
||||
'ai.providers.icon.upload': 'Загрузить изображение',
|
||||
'ai.providers.icon.reset': 'Сбросить',
|
||||
'ai.providers.icon.close': 'Свернуть',
|
||||
'ai.providers.icon.uploadedNote': 'Своя иконка (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Пожалуйста, выберите файл изображения.',
|
||||
'ai.providers.apiKey': 'API-ключ',
|
||||
'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.baseUrl.ollamaHelp': 'Локальный Ollama: http://localhost:11434/v1 (без API-ключа). Ollama Cloud: https://ollama.com/v1 и ключ Cloud.',
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Контекстное окно',
|
||||
'ai.providers.contextWindow.placeholder': 'например, 128000',
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе NetMesh применит безопасное значение по умолчанию.',
|
||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||
'ai.providers.refreshModels': 'Обновить модели',
|
||||
'ai.providers.test': 'Проверить',
|
||||
'ai.providers.test.testing': 'Проверка…',
|
||||
'ai.providers.test.ok': 'Подключено ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Эндпоинт доступен, но ответ выглядит неполным ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Подключено, но медленно ({latency} ms)',
|
||||
'ai.providers.test.error': 'Ошибка ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Сначала укажите Base URL',
|
||||
'ai.providers.test.missingApiKey': 'Сначала укажите API-ключ',
|
||||
'ai.providers.test.unavailable': 'Проверка соединения недоступна в этой среде',
|
||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||
'ai.providers.loadingModels': 'Загрузка моделей...',
|
||||
'ai.providers.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.providers.clickToLoadModels': 'Нажмите, чтобы загрузить модели',
|
||||
'ai.providers.showingModels': 'Показаны первые 100 из {count} моделей. Введите текст для фильтрации.',
|
||||
'ai.providers.advancedParams': 'Дополнительные параметры',
|
||||
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
|
||||
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
|
||||
'ai.codex.appServer.title': 'Использовать Codex App Server',
|
||||
'ai.codex.appServer.experimental': 'Экспериментально',
|
||||
'ai.codex.appServer.description': 'Постоянный протокол Codex с нативными подтверждениями, песочницей, актуальными моделями и вопросами во время выполнения. SDK остаётся режимом по умолчанию.',
|
||||
'ai.codex.appServer.checking': 'Проверка поддержки App Server…',
|
||||
'ai.codex.appServer.available': 'Эта версия Codex CLI поддерживает App Server.',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'Динамический каталог моделей Codex недоступен. Используется встроенный список моделей.',
|
||||
'ai.codex.appServer.approval.allowSession': 'Разрешить для сеанса',
|
||||
'ai.codex.appServer.userInput.title': 'Codex требуется ваш ответ',
|
||||
'ai.codex.appServer.userInput.description': 'Ответьте на вопросы, чтобы продолжить текущую задачу.',
|
||||
'ai.codex.appServer.userInput.other': 'Введите другой ответ',
|
||||
'ai.codex.appServer.userInput.autoResolve': 'Если ответа не будет вовремя, Codex продолжит автоматически.',
|
||||
'ai.codex.appServer.userInput.skip': 'Пропустить',
|
||||
'ai.codex.appServer.userInput.submit': 'Продолжить',
|
||||
'ai.codex.steer.addInstruction': 'Добавить инструкцию',
|
||||
'ai.codex.steer.sending': 'Инструкция добавляется…',
|
||||
'ai.codex.steer.placeholder': 'Добавьте инструкцию, пока Codex работает…',
|
||||
'ai.codex.steer.notSteerableReview': 'В этот сеанс проверки Codex нельзя добавить инструкцию. Черновик сохранён.',
|
||||
'ai.codex.steer.notSteerableCompact': 'В этот сеанс сжатия Codex нельзя добавить инструкцию. Черновик сохранён.',
|
||||
'ai.codex.steer.busy': 'Другая инструкция уже отправляется в Codex.',
|
||||
'ai.codex.steer.inactive': 'Задача Codex уже завершена. Черновик сохранён.',
|
||||
'ai.codex.steer.unsupported': 'Инструкции во время выполнения доступны только в режиме Codex App Server.',
|
||||
'ai.codex.steer.failed': 'Codex не принял дополнительную инструкцию. Черновик сохранён.',
|
||||
'ai.codex.detecting': 'Обнаружение...',
|
||||
'ai.codex.notFound': 'Не найден',
|
||||
'ai.codex.awaitingLogin': 'Ожидание входа',
|
||||
'ai.codex.connectedChatGPT': 'Подключено через ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Подключено через API-ключ',
|
||||
'ai.codex.connectedCustomConfig': 'Подключено через ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Обнаружен пользовательский конфиг (отсутствует переменная окружения)',
|
||||
'ai.codex.customConfigHint': 'Используется пользовательский провайдер "{provider}", настроенный в ~/.codex/config.toml — вход через ChatGPT не требуется.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Предупреждение: {envKey} не задана в переменных окружения вашей оболочки. Экспортируйте её (или запустите NetMesh из оболочки, где она задана), чтобы Codex мог пройти аутентификацию.',
|
||||
'ai.codex.notConnected': 'Не подключено',
|
||||
'ai.codex.statusUnknown': 'Статус неизвестен',
|
||||
'ai.codex.path': 'Путь:',
|
||||
'ai.codex.notFoundHint': 'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codex.customPathPlaceholder': 'например, /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Проверить',
|
||||
'ai.codex.resetPath': 'Сбросить',
|
||||
'ai.codex.openLogin': 'Открыть вход',
|
||||
'ai.codex.logout': 'Выйти',
|
||||
'ai.codex.connectChatGPT': 'Подключить ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Обновить статус',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Требует установленный в системе Claude Code CLI.',
|
||||
'ai.claude.detecting': 'Обнаружение...',
|
||||
'ai.claude.detected': 'Обнаружен',
|
||||
'ai.claude.notFound': 'Не найден',
|
||||
'ai.claude.path': 'Путь:',
|
||||
'ai.claude.notFoundHint': 'Не удалось найти claude в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.claude.customPathPlaceholder': 'например, /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Аутентификация и конфигурация (опционально)',
|
||||
'ai.claude.configDir': 'Каталог конфигурации',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
|
||||
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
|
||||
'ai.claude.settings': 'Файл настроек',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (путь или встроенный {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Опционально. Путь к settings.json или встроенный JSON, передаётся в SDK как `settings`. Дополняет «Каталог конфигурации» выше и независим от него (накладывается сверху, не заменяет).',
|
||||
'ai.claude.envVars': 'Переменные окружения',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
|
||||
'ai.claude.check': 'Проверить',
|
||||
'ai.claude.resetPath': 'Сбросить',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Использует GitHub Copilot CLI. После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.copilot.detecting': 'Обнаружение...',
|
||||
'ai.copilot.detected': 'Обнаружен',
|
||||
'ai.copilot.notFound': 'Не найден',
|
||||
'ai.copilot.path': 'Путь:',
|
||||
'ai.copilot.notFoundHint': 'Не удалось найти copilot в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Проверить',
|
||||
'ai.copilot.resetPath': 'Сбросить',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Использует Cursor SDK или локальный вход Agent CLI.',
|
||||
'ai.cursor.detecting': 'Обнаружение...',
|
||||
'ai.cursor.detected': 'Доступен',
|
||||
'ai.cursor.notFound': 'Недоступен',
|
||||
'ai.cursor.path': 'Среда:',
|
||||
'ai.cursor.notFoundHint': 'Укажите API-ключ или переключитесь на CLI login.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK / Agent CLI не обнаружен.',
|
||||
'ai.cursor.installStatus': 'Среда Cursor',
|
||||
'ai.cursor.installed': 'Обнаружено',
|
||||
'ai.cursor.notInstalled': 'Не обнаружено',
|
||||
'ai.cursor.modeCli': 'CLI login',
|
||||
'ai.cursor.modeApiKey': 'API Key',
|
||||
'ai.cursor.modeCliHint': 'Использует локальный `cursor-agent login` и квоту подписки Auto. Сохранённый API-ключ сохраняется, но в этом режиме не используется.',
|
||||
'ai.cursor.modeApiKeyHint': 'Использует платный Cursor API. CLI login в этом режиме игнорируется.',
|
||||
'ai.cursor.cliLoginStatus': 'CLI login',
|
||||
'ai.cursor.cliLoginOk': 'Вход выполнен',
|
||||
'ai.cursor.cliLoginAs': 'Вход как {{email}}',
|
||||
'ai.cursor.cliLoginMissing': 'Нет входа',
|
||||
'ai.cursor.cliLoginHint': 'Выполните `cursor-agent login` в терминале, затем нажмите Check.',
|
||||
'ai.cursor.apiKeyStatus': 'API-ключ',
|
||||
'ai.cursor.apiKeyConfigured': 'Настроен',
|
||||
'ai.cursor.apiKeyMissing': 'Не указан',
|
||||
'ai.cursor.apiKeyFromEnv': 'Из окружения',
|
||||
'ai.cursor.apiKey': 'API-ключ',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в NetMesh.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'NetMesh сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Сохранить',
|
||||
'ai.cursor.saved': 'Сохранено',
|
||||
'ai.cursor.showApiKey': 'Показать API-ключ',
|
||||
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
|
||||
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Проверить',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.codebuddy.detecting': 'Обнаружение...',
|
||||
'ai.codebuddy.detected': 'Обнаружен',
|
||||
'ai.codebuddy.notFound': 'Не найден',
|
||||
'ai.codebuddy.path': 'Путь:',
|
||||
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Проверить',
|
||||
'ai.codebuddy.resetPath': 'Сбросить',
|
||||
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
|
||||
'ai.codebuddy.internetEnv': 'Сетевая среда',
|
||||
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Устанавливает CODEBUDDY_INTERNET_ENVIRONMENT — выберите Internal или IOA для ограниченных сетевых сред.',
|
||||
'ai.codebuddy.envVars': 'Переменные окружения',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy требуется ваш ответ',
|
||||
'ai.codebuddy.elicitation.description': 'Проверьте запрос, чтобы продолжить текущий ход.',
|
||||
'ai.codebuddy.elicitation.select': 'Выберите вариант',
|
||||
'ai.codebuddy.elicitation.yes': 'Да',
|
||||
'ai.codebuddy.elicitation.no': 'Нет',
|
||||
'ai.codebuddy.elicitation.decline': 'Отклонить',
|
||||
'ai.codebuddy.elicitation.accept': 'Продолжить',
|
||||
'ai.codebuddy.elicitation.validation.required': 'Поле «{field}» обязательно.',
|
||||
'ai.codebuddy.elicitation.validation.invalidType': 'Поле «{field}» содержит недопустимое значение.',
|
||||
'ai.codebuddy.elicitation.validation.integer': 'Поле «{field}» должно быть целым числом.',
|
||||
'ai.codebuddy.elicitation.validation.notInteger': 'Поле «{field}» должно быть целым числом.',
|
||||
'ai.codebuddy.elicitation.validation.minimum': 'Значение «{field}» должно быть не меньше {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.maximum': 'Значение «{field}» должно быть не больше {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.minLength': 'Поле «{field}» должно содержать не менее {limit} символов.',
|
||||
'ai.codebuddy.elicitation.validation.maxLength': 'Поле «{field}» должно содержать не более {limit} символов.',
|
||||
'ai.codebuddy.elicitation.validation.minItems': 'Выберите не менее {limit} вариантов для «{field}».',
|
||||
'ai.codebuddy.elicitation.validation.maxItems': 'Выберите не более {limit} вариантов для «{field}».',
|
||||
'ai.codebuddy.elicitation.validation.format': 'Поле «{field}» должно соответствовать формату {format}.',
|
||||
'ai.codebuddy.elicitation.validation.option': 'Выберите допустимый вариант для «{field}».',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'Агент программирования Grok Build от xAI (CLI). Установите Grok CLI, выполните `grok login` или задайте XAI_API_KEY, затем выберите его как внешнего агента.',
|
||||
'ai.grok.detecting': 'Обнаружение...',
|
||||
'ai.grok.detected': 'Обнаружен',
|
||||
'ai.grok.notFound': 'Не найден',
|
||||
'ai.grok.path': 'Путь:',
|
||||
'ai.grok.notFoundHint': 'Не удалось найти grok в PATH. Установите Grok Build CLI или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.grok.customPathPlaceholder': 'например, /usr/local/bin/grok',
|
||||
'ai.grok.check': 'Проверить',
|
||||
'ai.grok.resetPath': 'Сбросить',
|
||||
'ai.grok.runtime.acp.title': 'Использовать Grok ACP (agent stdio)',
|
||||
'ai.grok.runtime.acp.default': 'По умолчанию',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'Подключение к Grok через Agent Client Protocol (grok agent stdio). NetMesh MCP внедряется в session/new. Выключите, чтобы использовать исходный headless streaming-json CLI.',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'Режим headless streaming-json (grok -p --output-format streaming-json). MCP внедряется через .grok/config.toml проекта.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Агент по умолчанию',
|
||||
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
|
||||
'ai.defaultAgent.catty': 'Catty (встроенный)',
|
||||
'ai.toolAccess.title': 'Доступ к инструментам',
|
||||
'ai.toolAccess.mode': 'Режим доступа NetMesh',
|
||||
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям NetMesh. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill NetMesh и команды CLI.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.toolAccess.mcpPrompt.title': 'Промпт для вашего ИИ-клиента',
|
||||
'ai.toolAccess.mcpPrompt.description': 'Вставьте этот промпт в ИИ-клиент (Codex, Claude Code и т. д.), и он сам зарегистрирует MCP NetMesh.',
|
||||
'ai.toolAccess.mcpPrompt.enableHint': 'Включите внешний MCP ниже, чтобы в промпт попал путь к лаунчеру.',
|
||||
'ai.toolAccess.skills.file': 'Файл skill',
|
||||
'ai.toolAccess.skills.description': 'В режиме Skills + CLI агенты автоматически получают путь к этому локальному skill-файлу. Путь к лаунчеру NetMesh CLI передаётся агенту в каждой сессии.',
|
||||
'ai.toolAccess.skills.unavailable': 'Путь к skill-файлу недоступен',
|
||||
|
||||
'ai.externalMcp.title': 'Внешний MCP',
|
||||
'ai.externalMcp.description': 'Откройте NetMesh как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите NetMesh запущенным, пока клиенты подключены.',
|
||||
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
|
||||
'ai.externalMcp.mode': 'Режим доступности',
|
||||
'ai.externalMcp.mode.temporary': 'Временный',
|
||||
'ai.externalMcp.mode.persistent': 'Постоянный',
|
||||
'ai.externalMcp.mode.description': 'Временный режим отключается после простоя. Постоянный восстанавливает External MCP при запуске NetMesh.',
|
||||
'ai.externalMcp.idleTimeout': 'Таймаут простоя',
|
||||
'ai.externalMcp.idleTimeout.description': 'В временном режиме отключить External MCP после указанного числа минут без MCP-операций.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'мин',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.',
|
||||
'ai.externalMcp.silentSessions': 'Бесшумные сеансы MCP',
|
||||
'ai.externalMcp.silentSessions.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'Автоматически закрывать сеансы, открытые ИИ, после указанного числа минут без операций терминала или файлов.',
|
||||
'ai.externalMcp.usage.title': 'Как пользоваться',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Включите External MCP и держите NetMesh запущенным.',
|
||||
'ai.externalMcp.usage.localhost': '2. Клиенты подключаются через локальный launcher (только 127.0.0.1). Discovery удаляется при отключении.',
|
||||
'ai.externalMcp.usage.permissions': '3. Запись следует Settings → AI → Safety и списку блокировки команд.',
|
||||
'ai.externalMcp.usage.capabilities': '4. Доступен полный catalog: терминал, SFTP, Vault и проброс портов. Секреты не возвращаются.',
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
|
||||
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты NetMesh без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
|
||||
'ai.externalMcp.permissionMode.unknown': 'Неизвестно',
|
||||
'ai.externalMcp.discovery': 'Discovery',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': 'Недоступно',
|
||||
'ai.externalMcp.bridgeUnavailable': 'Мост External MCP недоступен',
|
||||
'ai.externalMcp.copy': 'Копировать',
|
||||
'ai.externalMcp.copied': 'Скопировано',
|
||||
'ai.externalMcp.copyFailed': 'Не удалось скопировать. Скопируйте вручную.',
|
||||
'ai.externalMcp.refresh': 'Обновить',
|
||||
'ai.externalMcp.clientConfiguration': 'Настройка клиента',
|
||||
'ai.externalMcp.clientConfiguration.description': 'Выберите клиент для установки в один клик или скопируйте CLI / фрагмент конфига.',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'CLI-команда',
|
||||
'ai.externalMcp.configSnippet': 'Фрагмент конфига',
|
||||
'ai.externalMcp.addToCodex': 'Добавить в Codex',
|
||||
'ai.externalMcp.addToClaude': 'Добавить в Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Добавить в Grok',
|
||||
'ai.externalMcp.codexAdded': 'Запись MCP для Codex добавлена. Перезапустите Codex или откройте новую сессию.',
|
||||
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code добавлена. Перезапустите Claude Code или откройте новую сессию.',
|
||||
'ai.externalMcp.grokAdded': 'Запись MCP для Grok добавлена. Перезапустите Grok или откройте новую сессию.',
|
||||
'ai.externalMcp.installCodex': 'Сначала установите Codex, затем нажмите Обновить.',
|
||||
'ai.externalMcp.installClaude': 'Сначала установите Claude Code, затем нажмите Обновить.',
|
||||
'ai.externalMcp.installGrok': 'Сначала установите Grok CLI, затем нажмите Обновить.',
|
||||
'ai.externalMcp.conflict.description': 'Запись NetMesh-external уже существует и указывает в другое место. Удалите или измените её вручную.',
|
||||
'ai.externalMcp.enableForLauncher': 'Включите External MCP, чтобы получить путь launcher.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / другие клиенты',
|
||||
'ai.externalMcp.cursor.description': 'Объедините с MCP-конфигом (например ~/.cursor/mcp.json). Не заменяйте весь файл, если там уже есть другие серверы.',
|
||||
'ai.externalMcp.status.unavailable': 'Недоступно',
|
||||
'ai.externalMcp.status.disabled': 'Выключено',
|
||||
'ai.externalMcp.status.running': 'Работает',
|
||||
'ai.externalMcp.status.starting': 'Запуск',
|
||||
'ai.externalMcp.status.error': 'Ошибка',
|
||||
'ai.externalMcp.status.configured': 'Настроено',
|
||||
'ai.externalMcp.status.notConfigured': 'Не настроено',
|
||||
'ai.externalMcp.status.checking': 'Проверка',
|
||||
'ai.externalMcp.status.codexNotFound': 'Codex не найден',
|
||||
'ai.externalMcp.status.claudeNotFound': 'Claude Code не найден',
|
||||
'ai.externalMcp.status.grokNotFound': 'Grok не найден',
|
||||
'ai.externalMcp.status.conflict': 'Конфликт',
|
||||
'ai.userSkills.title': 'Пользовательские skills',
|
||||
'ai.userSkills.description': 'Откройте папку skills NetMesh, чтобы добавить свои каталоги skills. NetMesh автоматически сканирует их и добавляет только лёгкие индексы, если skill явно не соответствует текущему запросу.',
|
||||
'ai.userSkills.openFolder': 'Открыть папку skills',
|
||||
'ai.userSkills.reload': 'Перезагрузить skills',
|
||||
'ai.userSkills.location': 'Расположение',
|
||||
'ai.userSkills.loading': 'Сканирование пользовательских skills...',
|
||||
'ai.userSkills.summary': '{ready} готово, {warnings} предупреждений',
|
||||
'ai.userSkills.empty': 'Пользовательские skills пока не найдены. Откройте папку, чтобы добавить каталоги skills с файлом SKILL.md.',
|
||||
'ai.userSkills.unavailable': 'Пользовательские skills недоступны в этой среде.',
|
||||
'ai.userSkills.status.ready': 'Готово',
|
||||
'ai.userSkills.status.warning': 'Предупреждение',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Быстрые сообщения',
|
||||
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
|
||||
'ai.quickMessages.add': 'Добавить быстрое сообщение',
|
||||
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
|
||||
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
|
||||
'ai.quickMessages.name': 'Название',
|
||||
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
|
||||
'ai.quickMessages.slug': 'Команда',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
|
||||
'ai.quickMessages.content': 'Текст сообщения',
|
||||
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
|
||||
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
|
||||
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
|
||||
'ai.quickMessages.error.nameRequired': 'Укажите название.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
|
||||
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
|
||||
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
|
||||
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
|
||||
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
|
||||
'ai.chat.toolApproved': 'Одобрено',
|
||||
'ai.chat.toolApprovalHint': 'Enter — разрешить один раз, Escape — отклонить',
|
||||
'ai.chat.approve': 'Одобрить',
|
||||
'ai.chat.approveOnce': 'Разрешить один раз',
|
||||
'ai.chat.alwaysAllow': 'Всегда разрешать',
|
||||
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
|
||||
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
'ai.chat.rawCommand': 'Команда',
|
||||
'ai.chat.copyCommand': 'Копировать',
|
||||
'ai.chat.commandCopied': 'Скопировано',
|
||||
'ai.chat.approvalSession': 'Сессия',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Каталог',
|
||||
'ai.chat.approvalReason': 'Причина',
|
||||
'ai.chat.approvalInvocation': 'Вызов',
|
||||
'ai.chat.permissionRequired': 'Требуется разрешение',
|
||||
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
||||
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||
'ai.chat.recommendAllow': 'Разрешить',
|
||||
'ai.chat.recommendConfirm': 'Подтвердить',
|
||||
'ai.chat.recommendDeny': 'Запретить',
|
||||
'ai.chat.exportConversation': 'Экспортировать разговор',
|
||||
'ai.chat.exportAs': 'Экспортировать как',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Обычный текст',
|
||||
'ai.chat.thinking': 'Размышляет',
|
||||
'ai.chat.thoughtFor': 'Размышлял {duration}',
|
||||
'ai.chat.thought': 'Мысль',
|
||||
'ai.chat.agents': 'Агенты',
|
||||
'ai.chat.detectedOnMachine': 'Обнаружено на этом устройстве',
|
||||
'ai.chat.rescan': 'Пересканировать',
|
||||
'ai.chat.permObserver': 'Наблюдатель',
|
||||
'ai.chat.permConfirm': 'Подтверждение',
|
||||
'ai.chat.permAuto': 'Авто',
|
||||
'ai.chat.permObserverDesc': 'Только чтение',
|
||||
'ai.chat.permConfirmDesc': 'Спрашивать перед записью',
|
||||
'ai.chat.permAutoDesc': 'Выполнять свободно',
|
||||
'ai.chat.emptyHint': 'Спрашивайте о ваших серверах, запускайте команды или получайте помощь с конфигурациями.',
|
||||
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
|
||||
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
|
||||
'ai.chat.noModel': 'Нет модели',
|
||||
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
|
||||
'ai.chat.selectProvider': 'Выберите провайдера',
|
||||
'ai.chat.selectProviderAndModel': 'Выберите провайдера и модель',
|
||||
'ai.chat.selectModel': 'Выберите модель',
|
||||
'ai.chat.searchModels': 'Поиск моделей',
|
||||
'ai.chat.providers': 'Провайдеры',
|
||||
'ai.chat.models': 'Модели',
|
||||
'ai.chat.pinned': 'Закреплённые',
|
||||
'ai.chat.useCustomModel': 'Использовать «{id}»',
|
||||
'ai.chat.thinkingLevel': 'Мышление',
|
||||
'ai.chat.thinkingOff': 'Выкл.',
|
||||
'ai.chat.pinModel': 'Закрепить модель',
|
||||
'ai.chat.unpinModel': 'Открепить модель',
|
||||
'ai.chat.loadingModels': 'Загрузка моделей...',
|
||||
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
'ai.chat.untitled': 'Без названия',
|
||||
'ai.chat.justNow': 'Только что',
|
||||
'ai.chat.minutesAgo': '{n}м назад',
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
'ai.chat.daysAgo': '{n}д назад',
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||
'ai.chat.jumpUntitled': '(пустое сообщение)',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.menuHosts': 'Хосты',
|
||||
'ai.chat.menuContext': 'Контекст',
|
||||
'ai.chat.menuFiles': 'Файлы',
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuMentionNote': 'Упомянуть заметку',
|
||||
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
|
||||
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
|
||||
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
|
||||
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
|
||||
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
|
||||
'ai.chat.untitledNote': 'Заметка без названия',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские навыки',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
'ai.chat.slashSystemCommands': 'Системные команды',
|
||||
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
|
||||
'ai.chat.slashUserSkills': 'Пользовательские навыки',
|
||||
'ai.chat.quickMessages': 'Команды /',
|
||||
'ai.chat.slashNoResults': 'Нет подходящих команд',
|
||||
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите NetMesh или dev-процесс Electron и попробуйте снова.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Веб-поиск',
|
||||
'ai.webSearch.enable': 'Включить веб-поиск',
|
||||
'ai.webSearch.enable.description': 'Разрешить AI-агенту искать в интернете актуальную информацию.',
|
||||
'ai.webSearch.provider': 'Провайдер поиска',
|
||||
'ai.webSearch.provider.description': 'Выберите провайдера API веб-поиска.',
|
||||
'ai.webSearch.apiKey': 'API-ключ',
|
||||
'ai.webSearch.apiKey.description': 'API-ключ для выбранного провайдера поиска.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Введите API-ключ...',
|
||||
'ai.webSearch.apiHost': 'Адрес API',
|
||||
'ai.webSearch.apiHost.description': 'Пользовательская конечная точка API. Оставьте значение по умолчанию, если не используете прокси.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL вашего экземпляра SearXNG (обязательно).',
|
||||
'ai.webSearch.maxResults': 'Макс. число результатов',
|
||||
'ai.webSearch.maxResults.description': 'Максимальное количество результатов поиска для возврата (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Безопасность',
|
||||
'ai.safety.permissionMode': 'Режим разрешений',
|
||||
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через NetMesh и применяется как к встроенным, так и к внешним агентам. Режим подтверждения носит рекомендательный характер для внешних агентов (они управляют собственным потоком одобрения инструментов).',
|
||||
'ai.safety.permissionMode.observer': 'Наблюдатель — только чтение, без действий',
|
||||
'ai.safety.permissionMode.confirm': 'Подтверждение — спрашивать перед действиями',
|
||||
'ai.safety.permissionMode.auto': 'Авто — выполнять свободно',
|
||||
'ai.safety.commandTimeout': 'Тайм-аут команды',
|
||||
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
|
||||
'ai.safety.commandTimeout.unit': 'с',
|
||||
'ai.safety.responseIdleTimeout': 'Ожидание ответа встроенного ИИ',
|
||||
'ai.safety.responseIdleTimeout.description': 'Отменяет запрос встроенного ИИ после указанного числа секунд без нового ответа. Не управляет общей длительностью ответа и выполнением команд.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'с',
|
||||
'ai.safety.maxIterations': 'Макс. число итераций',
|
||||
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
|
||||
'ai.safety.blocklist': 'Чёрный список команд',
|
||||
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к внешним агентам через механизм выполнения NetMesh.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex-шаблон...',
|
||||
'ai.safety.blocklist.reset': 'Сбросить по умолчанию',
|
||||
'ai.safety.blocklist.add': 'Добавить шаблон',
|
||||
'ai.safety.grants.title': 'Память разрешений',
|
||||
'ai.safety.grants.heading': 'Правила для режима подтверждения',
|
||||
'ai.safety.grants.description': 'В режиме подтверждения действие сначала требует подтверждения. Сохранённые правила автоматически разрешают похожие действия во всех терминальных сессиях, их можно редактировать вручную.',
|
||||
'ai.safety.grants.empty': 'Правил пока нет. Выберите «Всегда разрешать» при одобрении или добавьте вручную.',
|
||||
'ai.safety.grants.capability': 'Идентификатор возможности',
|
||||
'ai.safety.grants.sessionPattern': 'Шаблон сессии',
|
||||
'ai.safety.grants.commandPattern': 'Шаблон команды (необяз.)',
|
||||
'ai.safety.grants.note': 'Заметка (необяз.)',
|
||||
'ai.safety.grants.add': 'Добавить правило',
|
||||
'ai.safety.grants.remove': 'Удалить',
|
||||
'ai.safety.grants.export': 'Экспорт JSON',
|
||||
'ai.safety.grants.import': 'Импорт JSON',
|
||||
'ai.safety.note': 'Эти настройки безопасности применяются к действиям, выполняемым через NetMesh. Внешние CLI-агенты могут иметь собственные локальные инструменты и собственные правила управления ими.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Добавить терминал',
|
||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
'terminal.layer.history': 'История',
|
||||
'terminal.layer.theme': 'Тема',
|
||||
'terminal.layer.notes': 'Заметки',
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.closePane': 'Закрыть область',
|
||||
'terminal.layer.resizeSplit': 'Изменить размер области',
|
||||
'terminal.layer.splitHorizontal': 'Разделить сверху и снизу',
|
||||
'terminal.layer.splitVertical': 'Разделить слева и справа',
|
||||
'terminal.layer.openInNewSplit': 'Открыть в новой области',
|
||||
'terminal.layer.hostTree.search': 'Поиск хостов...',
|
||||
'terminal.layer.hostTree.searchButton': 'Поиск',
|
||||
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
|
||||
'terminal.layer.hostTree.newHost': 'Новый хост',
|
||||
'terminal.layer.hostTree.newHostInGroup': 'Новый хост в этой группе',
|
||||
'terminal.layer.hostTree.editHost': 'Изменить хост',
|
||||
'terminal.layer.hostTree.hostSavedNextConnection': 'Хост обновлён. Параметры подключения вступят в силу при следующем подключении.',
|
||||
'terminal.layer.hostTree.newGroup': 'Новая группа',
|
||||
'terminal.layer.hostTree.localShell': 'Локальная оболочка',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'Нет доступных тегов',
|
||||
'terminal.layer.hostTree.clearTags': 'Сбросить выбор',
|
||||
'terminal.layer.hostTree.collapse': 'Свернуть список хостов',
|
||||
'terminal.layer.hostTree.expand': 'Развернуть список хостов',
|
||||
'terminal.layer.hostTree.empty': 'Хосты не найдены',
|
||||
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
|
||||
'topTabs.moreTabs': 'Больше вкладок',
|
||||
'topTabs.aiAssistant': 'AI-помощник',
|
||||
'topTabs.newLocalTerminal': 'Новый локальный терминал',
|
||||
'topTabs.controlPanel': 'Быстрые настройки',
|
||||
'topTabs.controlPanel.externalMcp': 'Внешний MCP',
|
||||
'topTabs.controlPanel.theme': 'Тема',
|
||||
'topTabs.controlPanel.theme.light': 'Светлая',
|
||||
'topTabs.controlPanel.theme.dark': 'Тёмная',
|
||||
'topTabs.controlPanel.theme.system': 'Системная',
|
||||
'topTabs.externalMcp.enable': 'Включить внешний MCP',
|
||||
'topTabs.externalMcp.disable': 'Отключить внешний MCP',
|
||||
'topTabs.windowOpacity': 'Прозрачность окна',
|
||||
'topTabs.openSettings': 'Открыть настройки',
|
||||
'ai.chat.sessionHistory': 'История сессий',
|
||||
'ai.chat.resizeInput': 'Перетащите, чтобы изменить высоту поля ввода',
|
||||
'ai.chat.attach': 'Прикрепить',
|
||||
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
|
||||
'ai.chat.terminalSelectionLines': 'строк: {count}',
|
||||
'ai.chat.collapse': 'Свернуть',
|
||||
'ai.chat.expand': 'Развернуть',
|
||||
'ai.chat.enableAgent': 'Включить {name}',
|
||||
'ai.chat.artifact.noteFallback': 'Заметка Vault',
|
||||
'ai.chat.artifact.openNotes': 'Открыть заметки',
|
||||
'ai.chat.artifact.openHosts': 'Открыть хосты',
|
||||
'ai.chat.artifact.notesSummary': '{count} заметок в Vault',
|
||||
'ai.chat.artifact.hostsSummary': '{count} хостов в Vault',
|
||||
'ai.chat.artifact.hostsAdded': 'Добавлено хостов: {count}',
|
||||
'ai.chat.artifact.hostsPreview': 'Предпросмотр хостов: {count}',
|
||||
'ai.chat.artifact.failed': 'Операция Vault не удалась',
|
||||
'ai.chat.artifact.unavailableTitle': 'Недоступно',
|
||||
'ai.chat.artifact.noteMissing': 'Эта заметка больше не в Vault.',
|
||||
'ai.chat.artifact.hostMissing': 'Этот хост больше не в Vault.',
|
||||
'ai.chat.artifact.snippetMissing': 'Этот сниппет или скрипт больше не находится в Vault.',
|
||||
'ai.chat.artifact.openSnippets': 'Открыть сниппеты',
|
||||
'ai.chat.artifact.snippetsSummary': 'Сниппетов в Vault: {count}',
|
||||
'ai.chat.artifact.scriptsSummary': 'Скриптов в Vault: {count}',
|
||||
'ai.chat.artifact.snippetFallback': 'Сниппет Vault',
|
||||
'ai.chat.artifact.scriptFallback': 'Сценарий автоматизации',
|
||||
'ai.chat.artifact.scriptLanguage': 'Скрипт: {language}',
|
||||
'ai.chat.artifact.snippetDeleted': 'Сниппет удалён',
|
||||
'ai.chat.artifact.scriptDeleted': 'Скрипт удалён',
|
||||
'ai.chat.artifact.snippetRan': 'Сниппет выполнен',
|
||||
'ai.chat.artifact.scriptStarted': 'Запуск скрипта начат',
|
||||
'ai.chat.artifact.scriptRunStatus': 'Запуск скрипта: {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': 'Запусков скриптов: {count}',
|
||||
'ai.chat.artifact.scriptRunStopped': 'Запуск скрипта остановлен',
|
||||
'ai.chat.artifact.scriptRunPaused': 'Запуск скрипта приостановлен',
|
||||
'ai.chat.artifact.scriptRunResumed': 'Запуск скрипта возобновлён',
|
||||
'ai.chat.artifact.scriptReference': 'Справка nct API',
|
||||
'zmodem.waitingForRemote': 'Ожидание удалённой стороны...',
|
||||
'zmodem.uploading': 'Загрузка',
|
||||
'zmodem.downloading': 'Скачивание',
|
||||
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Удалённый файл уже существует',
|
||||
'zmodem.overwrite.applyToRest': 'Применить к остальным конфликтам',
|
||||
'zmodem.overwrite.overwrite': 'Перезаписать',
|
||||
'zmodem.overwrite.skip': 'Пропустить',
|
||||
'zmodem.overwrite.cancel': 'Отмена',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
};
|
||||
1099
application/i18n/locales/ru/core.ts
Normal file
1099
application/i18n/locales/ru/core.ts
Normal file
File diff suppressed because it is too large
Load Diff
133
application/i18n/locales/ru/scripts.ts
Normal file
133
application/i18n/locales/ru/scripts.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export const ruScriptsMessages = {
|
||||
'scripts.meta.name': 'Название',
|
||||
'scripts.meta.language': 'Язык',
|
||||
'scripts.meta.description': 'Описание',
|
||||
'scripts.meta.descriptionPlaceholder': 'Необязательные заметки об этом скрипте',
|
||||
'scripts.meta.trigger': 'Триггер',
|
||||
'scripts.meta.triggerPattern': 'Шаблон вывода (регулярное выражение)',
|
||||
'scripts.meta.code': 'Скрипт',
|
||||
'scripts.trigger.manual': 'Ручной запуск',
|
||||
'scripts.trigger.onConnect': 'Запуск при подключении',
|
||||
'scripts.trigger.onOutput': 'Запуск при совпадении вывода',
|
||||
'scripts.trigger.onOutputHint': 'Срабатывает, когда вывод сервера совпадает с шаблоном (эхо нажатий клавиш игнорируется). Если целевые хосты не настроены, отслеживается текущая подключённая сессия; иначе — только указанные хосты. Не работает в приложениях альтернативного экрана, таких как vim или htop. Не запускается, пока в этой сессии выполняется другой скрипт; после его завершения проверка повторяется. Для ожидания внутри сценария используйте nct.screen.waitForText или nct.screen.waitForRegex в коде скрипта.',
|
||||
'scripts.actions.save': 'Сохранить',
|
||||
'scripts.actions.runNow': 'Запустить',
|
||||
'scripts.actions.openEditor': 'Открыть редактор',
|
||||
'scripts.actions.openEditorHint': 'Редактировать скрипт в большом окне',
|
||||
'scripts.editor.modalTitle': 'Редактор скриптов',
|
||||
'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.',
|
||||
'scripts.editor.lineCount': '{count} строк',
|
||||
'scripts.editor.resize': 'Изменить размер редактора',
|
||||
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
|
||||
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
|
||||
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
|
||||
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
|
||||
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
|
||||
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
'scripts.actions.runOnAllTabs': 'Запустить на всех вкладках',
|
||||
'scripts.actions.skippedConnectingSessions': 'Пропущено вкладок, которые ещё подключаются: {count}',
|
||||
'scripts.actions.skippedSensitiveSessions': 'Пропущено вкладок с паролем/чувствительным вводом: {count}',
|
||||
'scripts.actions.noRunnableHosts': 'Нет доступных для подключения хостов, соответствующих целям этого скрипта',
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
'scripts.running.status.paused': 'Приостановлен',
|
||||
'scripts.running.status.completed': 'Завершён',
|
||||
'scripts.running.status.failed': 'Ошибка',
|
||||
'scripts.running.waitingFor': 'Ожидание: {pattern}',
|
||||
'scripts.running.waitingForLabel': 'Ожидание',
|
||||
'scripts.running.waitingForShellPrompt': 'приглашение оболочки (# или $)',
|
||||
'scripts.running.lastSent': 'Отправлено: {command}',
|
||||
'scripts.recording.start': 'Начать запись',
|
||||
'scripts.recording.active': 'Остановить запись',
|
||||
'scripts.recording.startHint': 'Записывайте действия в активном терминале и создавайте код скрипта nct',
|
||||
'scripts.recording.unavailableHint': 'Запись здесь недоступна — используйте панель скриптов справа',
|
||||
'scripts.recording.activeHint': 'Идёт запись этого терминала. Вводите команды как обычно; нажмите «Остановить» или кнопку REC на панели инструментов, чтобы завершить и сохранить запись.',
|
||||
'scripts.recording.started': 'Запись начата — работайте в терминале',
|
||||
'scripts.recording.noSession': 'Сначала подключите терминал (одну вкладку или рабочее пространство)',
|
||||
'scripts.recording.alreadyActive': 'В другом терминале уже идёт запись',
|
||||
'scripts.recording.stop': 'Остановить запись',
|
||||
'scripts.recording.pause': 'Приостановить запись',
|
||||
'scripts.recording.resume': 'Продолжить запись',
|
||||
'scripts.recording.saveTitle': 'Сохранить записанный скрипт',
|
||||
'scripts.recording.namePlaceholder': 'Название скрипта',
|
||||
'scripts.recording.packagePlaceholder': 'Сохранить в папку',
|
||||
'scripts.recording.rootPackage': 'Корень',
|
||||
'scripts.recording.save': 'Сохранить',
|
||||
'scripts.recording.saveAndEdit': 'Сохранить и редактировать',
|
||||
'scripts.recording.helpTitle': 'Как записать скрипт',
|
||||
'scripts.recording.helpIntro': 'Запись превращает ваши действия в терминале в повторно используемый сценарий автоматизации — удобно для развёртываний, проверок состояния и других повторяющихся задач.',
|
||||
'scripts.recording.helpStep1': 'Подключитесь к хосту. Подойдут как отдельная вкладка терминала, так и терминал в рабочем пространстве — откройте панель скриптов справа.',
|
||||
'scripts.recording.helpStep2': 'Нажмите «Начать запись». Красный значок REC на панели инструментов терминала означает, что запись активна.',
|
||||
'scripts.recording.helpStep3': 'Вводите команды в терминале как обычно. Каждое нажатие Enter сохраняется как отдельный шаг.',
|
||||
'scripts.recording.helpStep4': 'Закончив работу, нажмите «Остановить запись» или кнопку остановки рядом с REC на панели инструментов терминала.',
|
||||
'scripts.recording.helpStep5': 'Укажите название и сохраните скрипт. Выберите «Сохранить и редактировать», если хотите доработать созданный код в редакторе.',
|
||||
'scripts.recording.helpTipsTitle': 'Советы',
|
||||
'scripts.recording.helpTip1': 'Паузы между действиями дольше одной секунды записываются как время ожидания, чтобы воспроизведение не было слишком быстрым.',
|
||||
'scripts.recording.helpTip2': 'После каждой команды запись ждёт приглашения оболочки (например, $ или #), прежде чем перейти к следующему шагу.',
|
||||
'scripts.recording.helpTip3': 'Ввод пароля отмечается как конфиденциальный и не сохраняется в скрипте открытым текстом.',
|
||||
'scripts.recording.helpTip4': 'Для vim, меню и других интерактивных сценариев сначала запишите основные команды, а затем доработайте скрипт вручную.',
|
||||
'scripts.dialog.title': 'Скрипт',
|
||||
'scripts.dialog.ok': 'ОК',
|
||||
'scripts.dialog.required': 'Обязательное поле',
|
||||
'scripts.dialog.numberInvalid': 'Введите корректное число',
|
||||
'scripts.dialog.numberMin': 'Значение должно быть не меньше {min}',
|
||||
'scripts.dialog.numberMax': 'Значение должно быть не больше {max}',
|
||||
'scripts.dialog.numberStep': 'Используйте шаг {step}',
|
||||
'scripts.dialog.waitForTimeoutTitle': 'Время ожидания истекло',
|
||||
'scripts.dialog.retry': 'Повторить',
|
||||
'scripts.dialog.skip': 'Пропустить',
|
||||
'scripts.dialog.abort': 'Прервать',
|
||||
'scripts.running.stepProgress': 'Шаг {current} / {total}',
|
||||
'scripts.running.determinateProgress': '{label} {current}/{total}',
|
||||
'scripts.running.progressFallback': 'Выполнение',
|
||||
'scripts.running.operationsCount': 'Операций: {count}',
|
||||
'scripts.running.opsPrefix': '',
|
||||
'scripts.running.opsSuffix': ' операций',
|
||||
'scripts.running.elapsedLabel': 'Прошло времени',
|
||||
'scripts.running.lastSentLabel': 'Отправлено:',
|
||||
'scripts.running.elapsed': '{elapsed}',
|
||||
'scripts.running.completedSummary': 'Завершено · операций: {count} · {elapsed}',
|
||||
'scripts.running.dismissHint': 'Нажмите закрытие, чтобы скрыть',
|
||||
'scripts.running.dismiss': 'Закрыть',
|
||||
'scripts.running.viewLogs': 'Просмотреть журнал',
|
||||
'scripts.running.logTitle': '{name} · Журнал запуска',
|
||||
'scripts.running.logEmpty': 'Журнал пока пуст',
|
||||
'scripts.running.pause': 'Приостановить',
|
||||
'scripts.running.resume': 'Продолжить',
|
||||
'scripts.running.stop': 'Остановить',
|
||||
'scripts.recording.saved': 'Скрипт сохранён',
|
||||
'scripts.recording.savedNamed': 'Сохранено: «{name}»',
|
||||
'scripts.observer.blocked': 'В режиме наблюдателя нельзя запускать скрипты, записывающие данные в терминал.',
|
||||
'vault.section.scripts': 'Скрипты',
|
||||
'vault.nav.scripts': 'Скрипты',
|
||||
'snippets.action.newScript': 'Новый сценарий автоматизации',
|
||||
'hostDetails.section.automation': 'Автоматизация',
|
||||
'hostDetails.automation.loginScript': 'Скрипт входа',
|
||||
'hostDetails.automation.loginScriptPlaceholder': 'Выберите скрипт',
|
||||
'hostDetails.automation.none': 'Нет',
|
||||
'hostDetails.automation.outputTriggers': 'Триггеры вывода',
|
||||
'hostDetails.automation.addTrigger': 'Добавить триггер',
|
||||
'hostDetails.automation.triggerPatternPlaceholder': 'Регулярное выражение',
|
||||
'hostDetails.automation.linkedScripts': 'Связанные скрипты',
|
||||
'hostDetails.automation.linkedScriptsEmpty': 'С этим хостом пока не связано ни одного скрипта.',
|
||||
'hostDetails.automation.linkScriptPlaceholder': 'Связать существующий скрипт…',
|
||||
'hostDetails.automation.unlink': 'Отвязать',
|
||||
'hostDetails.automation.equivalenceHint': 'Связь добавляет этот хост в список целей скрипта в разделе «Скрипты». Оба представления синхронизированы.',
|
||||
'hostDetails.automation.queueHint': 'Скрипты запускаются по порядку при подключении этого хоста. Сначала выполняются глобальные скрипты, затем эта очередь.',
|
||||
'hostDetails.automation.globalScripts': 'Глобальные скрипты подключения',
|
||||
'hostDetails.automation.globalScriptsHint': 'Выполняются первыми при каждом подключении. Порядок можно изменить в библиотеке скриптов.',
|
||||
'hostDetails.automation.connectQueue': 'Очередь запуска этого хоста',
|
||||
'hostDetails.automation.connectQueueEmpty': 'В очереди этого хоста пока нет скриптов подключения.',
|
||||
'hostDetails.automation.addToQueuePlaceholder': 'Добавить скрипт в очередь…',
|
||||
'hostDetails.automation.moveUp': 'Переместить вверх',
|
||||
'hostDetails.automation.moveDown': 'Переместить вниз',
|
||||
'hostDetails.automation.removeFromQueue': 'Удалить из очереди',
|
||||
'hostDetails.automation.dragHandle': 'Перетащите для изменения порядка',
|
||||
'hostDetails.automation.queueDragHint': 'Перетаскивайте элементы, чтобы изменить порядок очереди подключения.',
|
||||
};
|
||||
261
application/i18n/locales/ru/systemManager.ts
Normal file
261
application/i18n/locales/ru/systemManager.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'Система',
|
||||
|
||||
'systemManager.noSession': 'Нет активного терминального сеанса.',
|
||||
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.overview': 'Обзор',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.ports': 'Порты',
|
||||
'systemManager.tabs.services': 'Службы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'Разделы системного менеджера',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
|
||||
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
|
||||
'systemManager.errors.loadTmuxWindows': 'Не удалось загрузить окна tmux',
|
||||
'systemManager.errors.loadTmuxPanes': 'Не удалось загрузить панели tmux',
|
||||
'systemManager.errors.loadTmuxClients': 'Не удалось загрузить клиентов tmux',
|
||||
'systemManager.errors.actionFailed': 'Не удалось выполнить действие',
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
'systemManager.overview.loading': 'Загрузка обзора системы…',
|
||||
'systemManager.overview.memory': 'Память',
|
||||
'systemManager.overview.disk': 'Диск',
|
||||
'systemManager.overview.network': 'Сеть',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} ядер',
|
||||
'systemManager.overview.load': 'Нагрузка',
|
||||
'systemManager.overview.uptime': 'Время работы',
|
||||
'systemManager.overview.duration.daysHours': '{{days}} д {{hours}} ч',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}} ч {{minutes}} мин',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}} мин',
|
||||
'systemManager.overview.system': 'Система',
|
||||
'systemManager.overview.kernel': 'Ядро',
|
||||
'systemManager.overview.swap': 'Файл подкачки',
|
||||
'systemManager.overview.latency': 'Сетевая задержка SSH',
|
||||
'systemManager.overview.cpuCores': 'Ядра CPU',
|
||||
'systemManager.overview.disks': 'Диски',
|
||||
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
|
||||
'systemManager.overview.topProcesses': 'Процессы по памяти',
|
||||
'systemManager.overview.noData': 'Нет данных',
|
||||
'systemManager.overview.noDisks': 'Нет данных дисков',
|
||||
'systemManager.overview.noInterfaces': 'Нет данных интерфейсов',
|
||||
'systemManager.overview.noTopProcesses': 'Нет данных процессов',
|
||||
|
||||
'systemManager.processes.search': 'Поиск процессов…',
|
||||
'systemManager.processes.command': 'Команда',
|
||||
'systemManager.processes.user': 'Пользователь',
|
||||
'systemManager.processes.term': 'Завершить',
|
||||
'systemManager.processes.kill': 'Убить',
|
||||
'systemManager.processes.stop': 'Остановить (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Продолжить (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Сигнал SIGHUP',
|
||||
'systemManager.processes.renice': 'Изменить приоритет',
|
||||
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
|
||||
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'Все',
|
||||
'systemManager.processes.filter.running': 'Активные',
|
||||
'systemManager.processes.ppid': 'Родительский PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Виртуальный размер',
|
||||
'systemManager.processes.elapsed': 'Время работы',
|
||||
'systemManager.processes.stat': 'Состояние',
|
||||
'systemManager.processes.meta': '{{count}} проц.',
|
||||
'systemManager.processes.loading': 'Загрузка процессов…',
|
||||
'systemManager.processes.loadingMore': 'Загрузка следующих процессов…',
|
||||
'systemManager.processes.state.running': 'Активен',
|
||||
'systemManager.processes.state.sleeping': 'Спящий',
|
||||
'systemManager.processes.state.stopped': 'Остановлен',
|
||||
'systemManager.processes.state.zombie': 'Зомби',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'Память',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Команда',
|
||||
'systemManager.processes.sort.user': 'Пользователь',
|
||||
|
||||
'systemManager.common.dismiss': 'Закрыть',
|
||||
'systemManager.common.checkingAvailability': 'Проверка доступности…',
|
||||
'systemManager.common.loading': 'Загрузка…',
|
||||
'systemManager.common.loadingDetails': 'Загрузка деталей…',
|
||||
'systemManager.common.loadingStats': 'Загрузка статистики…',
|
||||
|
||||
'systemManager.tmux.new': 'Создать',
|
||||
'systemManager.tmux.search': 'Поиск сессий…',
|
||||
'systemManager.tmux.newSessionTitle': 'Новая сессия tmux',
|
||||
'systemManager.tmux.newSessionDesc': 'Задайте имя сессии и при необходимости команду запуска.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Своя команда',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'Из сниппета',
|
||||
'systemManager.tmux.pickSnippet': 'Из сниппетов',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'Сниппетов пока нет — добавьте их на панели скриптов или в хранилище.',
|
||||
'systemManager.tmux.selectedSnippet': 'Выбран сниппет: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Имя сессии',
|
||||
'systemManager.tmux.newSessionCommand': 'Команда запуска',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'например htop или npm run dev (необяз.)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Оставьте пустым для сессии с shell по умолчанию.',
|
||||
'systemManager.tmux.creating': 'Создание…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Сначала введите имя сессии',
|
||||
'systemManager.tmux.empty': 'Нет сессий tmux',
|
||||
'systemManager.tmux.attach': 'Подключить',
|
||||
'systemManager.tmux.attached': 'Подключена',
|
||||
'systemManager.tmux.detached': 'Отключена',
|
||||
'systemManager.tmux.windows': '{{count}} окон',
|
||||
'systemManager.tmux.created': 'Создана',
|
||||
'systemManager.tmux.activity': 'Активность',
|
||||
'systemManager.tmux.rename': 'Переименовать',
|
||||
'systemManager.tmux.detach': 'Отключить всех',
|
||||
'systemManager.tmux.killSession': 'Завершить сессию',
|
||||
'systemManager.tmux.killServer': 'Остановить сервер',
|
||||
'systemManager.tmux.loadingDetails': 'Загрузка деталей…',
|
||||
'systemManager.tmux.clients': 'Подключённые клиенты',
|
||||
'systemManager.tmux.windowList': 'Окна',
|
||||
'systemManager.tmux.newWindow': 'Новое окно',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
'systemManager.tmux.panes': '{{count}} пан.',
|
||||
'systemManager.tmux.active': 'активно',
|
||||
'systemManager.tmux.unnamedWindow': 'Безымянное окно',
|
||||
'systemManager.tmux.unnamedPane': 'Безымянная панель',
|
||||
'systemManager.tmux.attachWindow': 'Подключить к окну',
|
||||
'systemManager.tmux.selectWindow': 'Выбрать окно',
|
||||
'systemManager.tmux.killWindow': 'Закрыть окно',
|
||||
'systemManager.tmux.killPane': 'Закрыть панель',
|
||||
'systemManager.tmux.splitHorizontal': 'Разделить горизонтально',
|
||||
'systemManager.tmux.splitVertical': 'Разделить вертикально',
|
||||
'systemManager.tmux.sendKeys': 'Отправить клавиши',
|
||||
'systemManager.tmux.sendKeysTo': 'Отправить клавиши в окно {{window}} панель {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Команда или текст…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Переименовать сессию',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Переименовать окно',
|
||||
'systemManager.tmux.windowName': 'Имя окна',
|
||||
'systemManager.tmux.confirmKillSession': 'Завершить сессию tmux «{{name}}»?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Отключить всех клиентов от «{{name}}»?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Закрыть окно «{{name}}»?',
|
||||
'systemManager.tmux.confirmKillPane': 'Закрыть панель #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Остановить сервер tmux? Все сессии будут завершены.',
|
||||
'systemManager.tmux.meta': '{{count}} сессий',
|
||||
|
||||
'systemManager.docker.title': 'Контейнеры',
|
||||
'systemManager.docker.subTabs.containers': 'Контейнеры',
|
||||
'systemManager.docker.subTabs.images': 'Образы',
|
||||
'systemManager.docker.empty': 'Контейнеры не найдены',
|
||||
'systemManager.docker.imagesEmpty': 'Образы не найдены',
|
||||
'systemManager.docker.search': 'Поиск контейнеров…',
|
||||
'systemManager.docker.searchImages': 'Поиск образов…',
|
||||
'systemManager.docker.filter.all': 'Все',
|
||||
'systemManager.docker.filter.running': 'Запущены',
|
||||
'systemManager.docker.filter.stopped': 'Остановлены',
|
||||
'systemManager.docker.filter.paused': 'На паузе',
|
||||
'systemManager.docker.shell': 'Оболочка',
|
||||
'systemManager.docker.logs': 'Логи',
|
||||
'systemManager.docker.details': 'Детали',
|
||||
'systemManager.docker.inspect': 'Проверить',
|
||||
'systemManager.docker.imageInspect': 'Проверить образ',
|
||||
'systemManager.docker.confirmRemove': 'Удалить этот контейнер?',
|
||||
'systemManager.docker.confirmKill': 'Принудительно завершить контейнер?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Удалить образ «{{name}}»?',
|
||||
'systemManager.docker.confirmPrune': 'Удалить dangling-образы?',
|
||||
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
|
||||
'systemManager.docker.pause': 'Пауза',
|
||||
'systemManager.docker.unpause': 'Возобновить',
|
||||
'systemManager.docker.restart': 'Перезапустить',
|
||||
'systemManager.docker.kill': 'Принудительно завершить',
|
||||
'systemManager.docker.renamePrompt': 'Имя контейнера',
|
||||
'systemManager.docker.prune': 'Очистить',
|
||||
'systemManager.docker.pruneAll': 'Очистить всё',
|
||||
'systemManager.docker.tag': 'Добавить тег',
|
||||
'systemManager.docker.tagRepoPrompt': 'Имя репозитория',
|
||||
'systemManager.docker.tagNamePrompt': 'Имя тега',
|
||||
'systemManager.docker.meta': '{{count}} конт.',
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
'systemManager.ports.empty': 'Слушающие порты не найдены.',
|
||||
'systemManager.ports.search': 'Поиск портов…',
|
||||
'systemManager.ports.meta': '{{count}} слушающих',
|
||||
'systemManager.ports.filter.all': 'Все',
|
||||
'systemManager.ports.unknownProcess': 'Неизвестный процесс',
|
||||
'systemManager.ports.terminate': 'Завершить',
|
||||
'systemManager.ports.confirmTerminate': 'Отправить SIGTERM процессу {{pid}}, занимающему этот порт?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl недоступен на этом хосте.',
|
||||
'systemManager.services.loading': 'Загрузка служб systemd…',
|
||||
'systemManager.services.empty': 'Службы systemd не найдены.',
|
||||
'systemManager.services.search': 'Поиск служб…',
|
||||
'systemManager.services.meta': '{{count}} служб',
|
||||
'systemManager.services.filter.all': 'Все',
|
||||
'systemManager.services.filter.running': 'Запущены',
|
||||
'systemManager.services.filter.failed': 'С ошибкой',
|
||||
'systemManager.services.filter.inactive': 'Неактивны',
|
||||
'systemManager.services.start': 'Запустить',
|
||||
'systemManager.services.stop': 'Остановить',
|
||||
'systemManager.services.restart': 'Перезапустить',
|
||||
'systemManager.services.enable': 'Включить автозапуск',
|
||||
'systemManager.services.disable': 'Отключить автозапуск',
|
||||
'systemManager.services.reload': 'Перезагрузить',
|
||||
'systemManager.services.scope.user': 'user',
|
||||
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
|
||||
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
|
||||
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
|
||||
'systemManager.gpu.meta': '{{devices}} устройств · {{processes}} процессов',
|
||||
'systemManager.gpu.devices': 'Устройства',
|
||||
'systemManager.gpu.processes': 'Вычислительные процессы',
|
||||
'systemManager.gpu.noProcesses': 'Вычислительные процессы не сообщены.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Загрузка',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Температура',
|
||||
'systemManager.gpu.power': 'Питание',
|
||||
'systemManager.gpu.fan': 'Вентилятор {{value}}%',
|
||||
'systemManager.gpu.driver': 'Драйвер {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Статус',
|
||||
'systemManager.inspect.image': 'Образ',
|
||||
'systemManager.inspect.created': 'Создан',
|
||||
'systemManager.inspect.started': 'Запущен',
|
||||
'systemManager.inspect.restartPolicy': 'Перезапуск',
|
||||
'systemManager.inspect.command': 'Команда',
|
||||
'systemManager.inspect.ports': 'Порты',
|
||||
'systemManager.inspect.networks': 'Сети',
|
||||
'systemManager.inspect.mounts': 'Тома',
|
||||
'systemManager.inspect.env': 'Окружение',
|
||||
'systemManager.inspect.labels': 'Метки',
|
||||
'systemManager.inspect.tags': 'Теги',
|
||||
'systemManager.inspect.digests': 'Дайджесты',
|
||||
'systemManager.inspect.size': 'Размер',
|
||||
'systemManager.inspect.platform': 'Платформа',
|
||||
'systemManager.inspect.workdir': 'Рабочий каталог',
|
||||
'systemManager.inspect.exposedPorts': 'Открытые порты',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Скрыть JSON',
|
||||
};
|
||||
862
application/i18n/locales/ru/terminal.ts
Normal file
862
application/i18n/locales/ru/terminal.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
|
||||
'terminal.passwordPicker.title': 'Сохранённые пароли',
|
||||
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
|
||||
// Network Device Mode auto-detection tip (session header)
|
||||
'terminal.networkDevice.tip.message': 'Похоже на сетевое устройство. Включите режим сетевого устройства, чтобы команды отправлялись как есть (без обёртки оболочки).',
|
||||
'terminal.networkDevice.tip.action': 'Включить',
|
||||
'terminal.networkDevice.tip.dismiss': 'Скрыть',
|
||||
'terminal.networkDevice.tip.enabled': 'Режим сетевого устройства включён для {host}',
|
||||
// Connection logs
|
||||
'logs.table.date': 'Дата',
|
||||
'logs.table.user': 'Пользователь',
|
||||
'logs.table.host': 'Хост',
|
||||
'logs.table.saved': 'Сохранено',
|
||||
'logs.empty.title': 'Нет журналов подключений',
|
||||
'logs.empty.desc':
|
||||
'История ваших подключений будет отображаться здесь, когда вы подключаетесь к хостам или открываете локальные терминалы.',
|
||||
'logs.loadMore': 'Загрузить ещё {count} журналов',
|
||||
'logs.ongoing': 'в процессе',
|
||||
'logs.localTerminal': 'Локальный терминал',
|
||||
'logs.action.save': 'Сохранить',
|
||||
'logs.action.unsave': 'Убрать из сохранённых',
|
||||
'logs.action.delete': 'Удалить',
|
||||
|
||||
// Log view
|
||||
'logView.customizeAppearance': 'Настроить внешний вид',
|
||||
'logView.appearance': 'Внешний вид',
|
||||
'logView.readOnly': 'Только чтение',
|
||||
'logView.export': 'Экспорт',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Другие действия',
|
||||
'terminal.toolbar.scripts': 'Скрипты',
|
||||
'terminal.toolbar.history': 'История команд',
|
||||
'terminal.toolbar.configureOsc7': 'Настроить отслеживание каталога',
|
||||
'history.scope.label': 'Область истории',
|
||||
'history.tab.host': 'Хост',
|
||||
'history.tab.global': 'Глобальная',
|
||||
'history.searchPlaceholder': 'Поиск по истории...',
|
||||
'history.loading': 'Загрузка удалённой истории...',
|
||||
'history.meta.count': '{count} команд',
|
||||
'history.empty.noSession': 'Откройте удалённую сессию, чтобы просмотреть историю команд.',
|
||||
'history.empty.unsupportedProtocol': 'История команд доступна только для сессий SSH/Mosh/ET.',
|
||||
'history.empty.noHistory': 'История команд на этом хосте не найдена.',
|
||||
'history.empty.noGlobalHistory': 'Глобальной истории команд пока нет. Выполненные команды появятся здесь.',
|
||||
'history.action.refresh': 'Обновить',
|
||||
'history.action.retry': 'Повторить',
|
||||
'history.action.paste': 'Вставить в терминал',
|
||||
'history.action.run': 'Выполнить в терминале',
|
||||
'history.action.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'history.action.delete': 'Удалить из истории',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
|
||||
'terminal.toolbar.search': 'Поиск',
|
||||
'terminal.toolbar.startSessionLog': 'Начать журнал сессии',
|
||||
'terminal.toolbar.stopSessionLog': 'Остановить журнал сессии',
|
||||
'terminal.toolbar.timestampsEnable': 'Показать время',
|
||||
'terminal.toolbar.timestampsDisable': 'Скрыть время',
|
||||
'terminal.toolbar.broadcast': 'Трансляция',
|
||||
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
|
||||
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
|
||||
'terminal.toolbar.composeBar': 'Строка ввода',
|
||||
'terminal.composeBar.placeholder': 'Введите команду здесь и нажмите Enter для отправки...',
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
|
||||
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
|
||||
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
|
||||
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.paneMagnification.magnify': 'Увеличить текущую панель',
|
||||
'terminal.paneMagnification.restore': 'Восстановить раскладку панелей',
|
||||
'terminal.paneMagnification.hint': 'Увеличено',
|
||||
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||
'terminal.toolbar.dragPane': 'Перетащить панель терминала',
|
||||
'terminal.toolbar.showActions': 'Показать действия терминала',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Закрыть сессию',
|
||||
'terminal.toolbar.hostHighlight.title': 'Подсветка ключевых слов хоста',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'Для этого хоста не задано пользовательских правил подсветки',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Добавить новое правило',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Метка (например, Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex-шаблон (например, \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Некорректный regex-шаблон',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Очистить все',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Изменить цвет подсветки для',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Выбрать цвет для нового правила',
|
||||
'terminal.statusbar.copyHostname.label': 'Копировать адрес хоста',
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
|
||||
'terminal.statusbar.disconnect.label': 'Отключить',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Отключить сессию, не закрывая вкладку',
|
||||
'terminal.statusbar.reconnect.label': 'Переподключиться',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
|
||||
'terminal.serverStats.memUsed': 'Использовано',
|
||||
'terminal.serverStats.memBuffers': 'Буферы',
|
||||
'terminal.serverStats.memCached': 'Кэш',
|
||||
'terminal.serverStats.memFree': 'Свободно',
|
||||
'terminal.serverStats.swap': 'Файл подкачки',
|
||||
'terminal.serverStats.swapUsed': 'Использовано swap',
|
||||
'terminal.serverStats.swapFree': 'Свободный swap',
|
||||
'terminal.serverStats.swapTotal': 'Всего',
|
||||
'terminal.serverStats.topProcesses': 'Топ процессов по памяти',
|
||||
'terminal.serverStats.disk': 'Использование дисков',
|
||||
'terminal.serverStats.diskDetails': 'Смонтированные диски',
|
||||
'terminal.serverStats.network': 'Скорость сети',
|
||||
'terminal.serverStats.latency': 'Сетевая задержка SSH',
|
||||
'terminal.serverStats.networkDetails': 'Сетевые интерфейсы',
|
||||
'terminal.serverStats.noData': 'Данные недоступны',
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
|
||||
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
'terminal.dragDrop.destinationUnknown': 'Не удалось определить текущую папку терминала. Включите отслеживание каталогов или сначала откройте SFTP и выберите папку загрузки.',
|
||||
'terminal.dragDrop.uploadCancelled': 'Загрузка отменена: соединение терминала изменилось или его не удалось повторно использовать. После переподключения перетащите файлы снова.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'Эта папка недоступна для записи пользователю входа. Включите повышение прав Sudo в настройках хоста или вернитесь в домашний каталог и перетащите файлы снова.',
|
||||
'terminal.search.placeholder': 'Поиск...',
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
|
||||
'terminal.menu.addSelectionToAI': 'Добавить в чат',
|
||||
'terminal.menu.pasteSelection': 'Вставить выделенное',
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Получить через YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.menu.rename': 'Переименовать',
|
||||
'terminal.menu.detach': 'Открепить из рабочей области',
|
||||
'terminal.menu.detachSession': 'Открепить {name}',
|
||||
'terminal.osc7Setup.title': 'Настроить отслеживание каталога',
|
||||
'terminal.osc7Setup.desc': 'NetMesh добавит хуки OSC 7 для текущего удалённого пользователя. Это помогает SFTP следовать за каталогом терминала после sudo или su.',
|
||||
'terminal.osc7Setup.targets': 'Возможные файлы для обновления',
|
||||
'terminal.osc7Setup.command': 'Команда для запуска',
|
||||
'terminal.osc7Setup.run': 'Запустить настройку',
|
||||
'terminal.osc7Setup.running': 'Настройка...',
|
||||
'terminal.osc7Setup.configured': 'Отслеживание каталога настроено',
|
||||
'terminal.osc7Setup.failed': 'Не удалось настроить отслеживание каталога',
|
||||
'terminal.osc7Setup.sent': 'Настройка отслеживания каталога отправлена в терминал',
|
||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||
'terminal.ymodem.allFiles': 'Все файлы',
|
||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
|
||||
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Выберите папку для полученных файлов',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM получает...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM получил {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM получил файлов: {count}',
|
||||
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
|
||||
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM недоступен',
|
||||
'terminal.clipboardImageUpload.noImage': 'В буфере обмена нет изображения',
|
||||
'terminal.clipboardImageUpload.failed': 'Не удалось загрузить изображение из буфера обмена',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
'terminal.auth.sshKey': 'SSH-ключ',
|
||||
'terminal.auth.username': 'Имя пользователя',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': 'Пароль',
|
||||
'terminal.auth.password.placeholder': 'Введите пароль',
|
||||
'terminal.auth.passphrase': 'Парольная фраза',
|
||||
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
|
||||
'terminal.auth.certificate': 'Сертификат',
|
||||
'terminal.auth.selectKey': 'Выбрать ключ',
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Учётные данные прокси не могут быть расшифрованы на этом устройстве. Откройте настройки хоста и заново введите пароль прокси.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Сохранённый SSH-ключ недоступен на этом устройстве. Выполняется переход на аутентификацию по паролю.',
|
||||
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
|
||||
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
|
||||
'terminal.progress.reconnecting': 'Повторное подключение...',
|
||||
'terminal.progress.autoReconnectScheduled': 'Соединение потеряно. Повторное подключение через {seconds} с (попытка {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Попытка автоматического подключения {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
|
||||
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
|
||||
'terminal.connection.showLogs': 'Показать журналы',
|
||||
'terminal.connection.hideLogs': 'Скрыть журналы',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.plugin': 'Подключение плагина',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Локальная оболочка',
|
||||
'terminal.hostKey.unknownTitle': 'Подтвердите этот ключ хоста',
|
||||
'terminal.hostKey.changedTitle': 'Ключ хоста изменился',
|
||||
'terminal.hostKey.unknownDescription': 'Подлинность {host} пока не может быть установлена.',
|
||||
'terminal.hostKey.changedDescription': 'Сохранённый ключ для {host} больше не совпадает с этим сервером.',
|
||||
'terminal.hostKey.fingerprintLabel': 'Отпечаток {keyType} — SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': 'Сохранённый отпечаток',
|
||||
'terminal.hostKey.unknownHint': 'Запомните его, если этот отпечаток принадлежит серверу, к которому вы ожидали подключиться.',
|
||||
'terminal.hostKey.changedHint': 'Продолжайте только если вы ожидали, что этот хост изменится.',
|
||||
'terminal.hostKey.addAndContinue': 'Добавить и продолжить',
|
||||
'terminal.hostKey.updateAndContinue': 'Обновить и продолжить',
|
||||
'terminal.themeModal.title': 'Внешний вид терминала',
|
||||
'terminal.themeModal.tab.theme': 'Тема',
|
||||
'terminal.themeModal.tab.font': 'Шрифт',
|
||||
'terminal.themeModal.tab.custom': 'Пользовательское',
|
||||
'terminal.themeModal.globalTheme': 'Глобальная тема',
|
||||
'terminal.themeModal.globalFont': 'Глобальный шрифт',
|
||||
'terminal.themeModal.fontSize': 'Размер шрифта',
|
||||
'terminal.themeModal.fontWeight': 'Толщина шрифта',
|
||||
'terminal.themeModal.livePreview': 'Предпросмотр в реальном времени',
|
||||
'terminal.themeModal.themeType': 'Тема {type}',
|
||||
'terminal.hiddenTheme.title': 'Текущая скрытая тема',
|
||||
'terminal.hiddenTheme.desc': 'Эта тема скрыта из ручного выбора и будет заменена, когда вы выберете другую тему.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'Активна системная тема',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Откройте настройки, чтобы выбрать фиксированную светлую или тёмную тему.',
|
||||
'topTabs.toggleTheme.openSettings': 'Открыть настройки',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Пользовательские темы',
|
||||
'terminal.customTheme.yourThemes': 'Ваши темы',
|
||||
'terminal.customTheme.new': 'Новая тема',
|
||||
'terminal.customTheme.newDesc': 'Клонировать текущую тему и настроить её',
|
||||
'terminal.customTheme.newTitle': 'Новая пользовательская тема',
|
||||
'terminal.customTheme.editTitle': 'Редактировать тему',
|
||||
'terminal.customTheme.import': 'Импорт .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Импорт из файла цветовой схемы iTerm2',
|
||||
'terminal.customTheme.importError': 'Не удалось разобрать выбранный файл. Убедитесь, что это корректный XML-файл .itermcolors.',
|
||||
'terminal.customTheme.delete': 'Удалить тему',
|
||||
'terminal.customTheme.confirmDelete': 'Подтвердить удаление',
|
||||
'terminal.customTheme.name': 'Название',
|
||||
'terminal.customTheme.namePlaceholder': 'Моя пользовательская тема',
|
||||
'terminal.customTheme.type': 'Тип',
|
||||
'terminal.customTheme.group.general': 'Общие',
|
||||
'terminal.customTheme.group.normal': 'Обычные цвета',
|
||||
'terminal.customTheme.group.bright': 'Яркие цвета',
|
||||
'terminal.customTheme.color.background': 'Фон',
|
||||
'terminal.customTheme.color.foreground': 'Текст',
|
||||
'terminal.customTheme.color.cursor': 'Курсор',
|
||||
'terminal.customTheme.color.selection': 'Выделение',
|
||||
'terminal.customTheme.color.black': 'Чёрный',
|
||||
'terminal.customTheme.color.red': 'Красный',
|
||||
'terminal.customTheme.color.green': 'Зелёный',
|
||||
'terminal.customTheme.color.yellow': 'Жёлтый',
|
||||
'terminal.customTheme.color.blue': 'Синий',
|
||||
'terminal.customTheme.color.magenta': 'Пурпурный',
|
||||
'terminal.customTheme.color.cyan': 'Голубой',
|
||||
'terminal.customTheme.color.white': 'Белый',
|
||||
'terminal.customTheme.color.brightBlack': 'Яркий чёрный',
|
||||
'terminal.customTheme.color.brightRed': 'Яркий красный',
|
||||
'terminal.customTheme.color.brightGreen': 'Яркий зелёный',
|
||||
'terminal.customTheme.color.brightYellow': 'Яркий жёлтый',
|
||||
'terminal.customTheme.color.brightBlue': 'Яркий синий',
|
||||
'terminal.customTheme.color.brightMagenta': 'Яркий пурпурный',
|
||||
'terminal.customTheme.color.brightCyan': 'Яркий голубой',
|
||||
'terminal.customTheme.color.brightWhite': 'Яркий белый',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'Синхронизация с end-to-end шифрованием',
|
||||
'cloudSync.gate.desc':
|
||||
'Ваши данные шифруются локально перед синхронизацией. Облачные провайдеры никогда не видят ваши данные в открытом виде. Задайте мастер-ключ, чтобы включить безопасную синхронизацию.',
|
||||
'cloudSync.gate.masterKey': 'Мастер-ключ',
|
||||
'cloudSync.gate.confirmMasterKey': 'Подтвердите мастер-ключ',
|
||||
'cloudSync.gate.placeholder': 'Введите надёжный пароль',
|
||||
'cloudSync.gate.confirmPlaceholder': 'Подтвердите пароль',
|
||||
'cloudSync.gate.mismatch': 'Пароли не совпадают',
|
||||
'cloudSync.gate.warning':
|
||||
'Я понимаю, что если забуду мастер-ключ, мои данные нельзя будет восстановить. Сброс пароля невозможен.',
|
||||
'cloudSync.gate.enableVault': 'Включить зашифрованное хранилище',
|
||||
'cloudSync.gate.enabledToast': 'Зашифрованное хранилище включено',
|
||||
'cloudSync.gate.setupFailed': 'Не удалось настроить мастер-ключ',
|
||||
'cloudSync.passwordStrength.tooShort': 'Слишком короткий',
|
||||
'cloudSync.passwordStrength.weak': 'Слабый',
|
||||
'cloudSync.passwordStrength.moderate': 'Средний',
|
||||
'cloudSync.passwordStrength.strong': 'Сильный',
|
||||
'cloudSync.passwordStrength.veryStrong': 'Очень сильный',
|
||||
'cloudSync.provider.notConnected': 'Не подключено',
|
||||
'cloudSync.provider.sync': 'Синхронизация',
|
||||
'cloudSync.provider.connect': 'Подключить',
|
||||
'cloudSync.provider.connecting': 'Подключение...',
|
||||
'cloudSync.provider.disconnect': 'Отключить',
|
||||
'cloudSync.provider.disconnect.confirmTitle': 'Отключить "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Локальное хранилище останется на этом компьютере.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Отключить',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'Совместимое с S3',
|
||||
'cloudSync.provider.s3.desc': 'Подключение к объектному хранилищу, совместимому с S3',
|
||||
'cloudSync.provider.comingSoon': 'Скоро',
|
||||
'cloudSync.webdav.title': 'Настройки WebDAV',
|
||||
'cloudSync.webdav.desc': 'Настройка WebDAV endpoint для зашифрованной синхронизации.',
|
||||
'cloudSync.webdav.endpoint': 'Конечная точка URL',
|
||||
'cloudSync.webdav.authType': 'Тип аутентификации',
|
||||
'cloudSync.webdav.auth.basic': 'Базовая',
|
||||
'cloudSync.webdav.auth.digest': 'Дайджест',
|
||||
'cloudSync.webdav.auth.token': 'Токен',
|
||||
'cloudSync.webdav.username': 'Имя пользователя',
|
||||
'cloudSync.webdav.password': 'Пароль',
|
||||
'cloudSync.webdav.token': 'Токен',
|
||||
'cloudSync.webdav.showSecret': 'Показать секрет',
|
||||
'cloudSync.webdav.allowInsecure': 'Разрешить небезопасное соединение (игнорировать ошибки сертификата)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Введите корректный WebDAV endpoint.',
|
||||
'cloudSync.webdav.validation.credentials': 'Имя пользователя и пароль обязательны.',
|
||||
'cloudSync.webdav.validation.token': 'Токен обязателен.',
|
||||
'cloudSync.s3.title': 'Настройки S3',
|
||||
'cloudSync.s3.desc': 'Подключение к объектному хранилищу, совместимому с S3, для зашифрованной синхронизации.',
|
||||
'cloudSync.s3.endpoint': 'Конечная точка URL',
|
||||
'cloudSync.s3.region': 'Регион',
|
||||
'cloudSync.s3.bucket': 'Бакет',
|
||||
'cloudSync.s3.accessKeyId': 'ID ключа доступа',
|
||||
'cloudSync.s3.secretAccessKey': 'Секретный ключ доступа',
|
||||
'cloudSync.s3.sessionToken': 'Токен сессии (необязательно)',
|
||||
'cloudSync.s3.prefix': 'Префикс ключа (необязательно)',
|
||||
'cloudSync.s3.forcePathStyle': 'Принудительно использовать path-style URL (для MinIO/R2 и т. д.)',
|
||||
'cloudSync.s3.allowInsecure': 'Разрешить небезопасное соединение (игнорировать ошибки сертификата)',
|
||||
'cloudSync.s3.showSecret': 'Показать секреты',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, регион, бакет, access key и secret обязательны.',
|
||||
'cloudSync.smb.title': 'Настройки SMB',
|
||||
'cloudSync.smb.desc': 'Подключение к файловой SMB/CIFS-шаре для зашифрованной синхронизации.',
|
||||
'cloudSync.smb.share': 'Путь к шаре',
|
||||
'cloudSync.smb.username': 'Имя пользователя',
|
||||
'cloudSync.smb.password': 'Пароль',
|
||||
'cloudSync.smb.domain': 'Домен (необязательно)',
|
||||
'cloudSync.smb.domainPlaceholder': 'например, WORKGROUP',
|
||||
'cloudSync.smb.port': 'Порт (необязательно)',
|
||||
'cloudSync.smb.showSecret': 'Показать пароль',
|
||||
'cloudSync.smb.validation.share': 'Путь к шаре обязателен.',
|
||||
'cloudSync.smb.validation.port': 'Порт должен быть числом от 1 до 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB успешно подключён',
|
||||
'cloudSync.connect.smb.failedTitle': 'Ошибка подключения SMB',
|
||||
'cloudSync.provider.smb': 'SMB-шара',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV успешно подключён',
|
||||
'cloudSync.connect.webdav.failedTitle': 'Ошибка подключения WebDAV',
|
||||
'cloudSync.connect.s3.success': 'S3 успешно подключён',
|
||||
'cloudSync.connect.s3.failedTitle': 'Ошибка подключения S3',
|
||||
'cloudSync.connect.plugin.success': 'Провайдер синхронизации плагина подключен',
|
||||
'cloudSync.connect.plugin.failedTitle': 'Ошибка подключения синхронизации плагина',
|
||||
'cloudSync.pluginConfig.title': 'Настройка {name}',
|
||||
'cloudSync.pluginConfig.desc': 'Введите JSON-конфигурацию, требуемую этим провайдером синхронизации.',
|
||||
'cloudSync.pluginConfig.label': 'Конфигурация провайдера (JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': 'Конфигурация должна быть допустимым JSON.',
|
||||
'cloudSync.pluginConfig.schemaInvalid': 'Конфигурация не соответствует схеме провайдера.',
|
||||
'cloudSync.lastSync.never': 'Никогда',
|
||||
'cloudSync.lastSync.justNow': 'Только что',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} мин назад',
|
||||
'cloudSync.changeKey': 'Изменить ключ',
|
||||
'cloudSync.providers.title': 'Облачные провайдеры',
|
||||
'cloudSync.syncAll': 'Синхронизировать всех подключённых провайдеров',
|
||||
'cloudSync.autoSync.title': 'Автосинхронизация',
|
||||
'cloudSync.autoSync.desc': 'Автоматически синхронизировать при внесении изменений',
|
||||
'cloudSync.strategy.title': 'Стратегия синхронизации',
|
||||
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
|
||||
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
|
||||
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если NetMesh не сможет безопасно выбрать, он попросит вас решить вручную.',
|
||||
'cloudSync.strategy.preferCloud': 'Приоритет облака',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
|
||||
'cloudSync.strategy.preferLocal': 'Приоритет локальных данных',
|
||||
'cloudSync.strategy.preferLocalDesc': 'Когда изменились обе стороны, загрузить локальную версию и заменить облачные изменения.',
|
||||
'cloudSync.convergent.title': 'Сходящаяся синхронизация устройств',
|
||||
'cloudSync.convergent.experimental': 'Экспериментально',
|
||||
'cloudSync.convergent.desc': 'Зашифрованная CRDT-реплика сохраняет офлайн-правки, параллельные удаления и изменения всех подключённых провайдеров.',
|
||||
'cloudSync.convergent.active': 'CRDT v2 активна. Каждая загрузка проверяется чтением из облака.',
|
||||
'cloudSync.convergent.paused': 'CRDT v2 приостановлена на этом устройстве; облачные метаданные сохранены.',
|
||||
'cloudSync.convergent.enabled': 'Сходящаяся синхронизация включена',
|
||||
'cloudSync.convergent.preview.title': 'Предпросмотр миграции',
|
||||
'cloudSync.convergent.preview.entities': 'Объекты',
|
||||
'cloudSync.convergent.preview.providers': 'Провайдеры',
|
||||
'cloudSync.convergent.preview.conflicts': 'Конфликты',
|
||||
'cloudSync.convergent.preview.compatibility': 'Каждая зашифрованная нагрузка сохраняет полный снимок v1 для старых клиентов.',
|
||||
'cloudSync.convergent.preview.confirm': 'Создать CRDT-реплику',
|
||||
'cloudSync.convergent.preview.status.ready': 'Готово',
|
||||
'cloudSync.convergent.preview.status.empty': 'Пусто',
|
||||
'cloudSync.convergent.preview.status.unavailable': 'Недоступно',
|
||||
'cloudSync.convergent.preview.status.blocked': 'Заблокировано',
|
||||
'cloudSync.convergent.preview.schema': 'схема',
|
||||
'cloudSync.convergent.field.presence': 'наличие',
|
||||
'cloudSync.convergent.field.position': 'позиция',
|
||||
'cloudSync.convergent.conflicts.title': 'Конфликты полей ({count})',
|
||||
'cloudSync.convergent.conflict.empty': 'Пусто / удалено',
|
||||
'cloudSync.convergent.conflict.secretSet': 'Секрет задан',
|
||||
'cloudSync.convergent.conflict.current': 'текущий вариант',
|
||||
'cloudSync.convergent.conflict.choose': 'Выбрать',
|
||||
'cloudSync.convergent.conflict.resolved': 'Конфликт разрешён и синхронизирован',
|
||||
'cloudSync.convergent.downgrade.desc': 'Заменить файлы v2 у всех подключённых провайдеров снимком старого формата.',
|
||||
'cloudSync.convergent.downgrade.button': 'Понизить версию',
|
||||
'cloudSync.convergent.downgrade.confirm': 'Понизить формат у всех подключённых провайдеров? Метаданные CRDT будут удалены после проверки записи.',
|
||||
'cloudSync.convergent.downgrade.done': 'Формат сходящейся синхронизации понижен',
|
||||
'cloudSync.status.title': 'Статус синхронизации',
|
||||
'cloudSync.status.localVersion': 'Локальная версия',
|
||||
'cloudSync.status.remoteVersion': 'Удалённая версия',
|
||||
'cloudSync.history.title': 'История синхронизации',
|
||||
'cloudSync.history.upload': 'Загрузка',
|
||||
'cloudSync.history.download': 'Скачивание',
|
||||
'cloudSync.history.resolved': 'Разрешено',
|
||||
'cloudSync.history.error': 'Ошибка',
|
||||
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
||||
'cloudSync.localBackups.desc': 'NetMesh сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
||||
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить NetMesh.',
|
||||
'cloudSync.localBackups.maxCount': 'Макс. число копий',
|
||||
'cloudSync.localBackups.maxSaved': 'Хранение резервных копий: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Введите число от 1 до 100.',
|
||||
'cloudSync.localBackups.empty': 'Локальных резервных копий пока нет.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Перед сменой версии приложения',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Перед восстановлением',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок',
|
||||
'cloudSync.localBackups.restore': 'Восстановить',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
|
||||
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Восстановить',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Отмена',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Локальные резервные копии недоступны',
|
||||
'cloudSync.localBackups.unavailableDesc': 'Эта платформа не предоставляет NetMesh безопасное хранилище ключей, поэтому локальные резервные копии нельзя записывать безопасно. Установите NetMesh в систему с поддерживаемым keychain, чтобы включить историю локальных резервных копий.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Требуется мастер-ключ',
|
||||
'cloudSync.localBackups.lockedDesc': 'Настройте или разблокируйте мастер-ключ перед восстановлением резервной копии, чтобы восстановленные учётные данные оставались зашифрованными.',
|
||||
'cloudSync.revisionHistory.viewButton': 'История',
|
||||
'cloudSync.revisionHistory.title': 'История версий хранилища',
|
||||
'cloudSync.revisionHistory.description': 'Просматривайте и восстанавливайте предыдущие версии вашего хранилища из истории ревизий Gist.',
|
||||
'cloudSync.revisionHistory.empty': 'Ревизии не найдены.',
|
||||
'cloudSync.revisionHistory.current': 'Текущая',
|
||||
'cloudSync.revisionHistory.revision': 'Ревизия',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Содержимое ревизии',
|
||||
'cloudSync.revisionHistory.device': 'Устройство',
|
||||
'cloudSync.revisionHistory.hosts': 'Хосты',
|
||||
'cloudSync.revisionHistory.keys': 'Ключи',
|
||||
'cloudSync.revisionHistory.snippets': 'Сниппеты',
|
||||
'cloudSync.revisionHistory.notes': 'Заметки',
|
||||
'cloudSync.revisionHistory.identities': 'Идентификаторы',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Восстановить эту версию',
|
||||
'cloudSync.revisionHistory.restored': 'Хранилище восстановлено из выбранной ревизии.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Ревизия не найдена или не содержит данных хранилища.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'Не удалось расшифровать эту ревизию. Возможно, она была зашифрована другим мастер-паролем.',
|
||||
'cloudSync.changeKey.title': 'Изменить мастер-ключ',
|
||||
'cloudSync.changeKey.current': 'Текущий мастер-ключ',
|
||||
'cloudSync.changeKey.new': 'Новый мастер-ключ',
|
||||
'cloudSync.changeKey.confirmNew': 'Подтвердите новый мастер-ключ',
|
||||
'cloudSync.changeKey.currentPlaceholder': 'Введите текущий мастер-ключ',
|
||||
'cloudSync.changeKey.newPlaceholder': 'Введите новый мастер-ключ',
|
||||
'cloudSync.changeKey.confirmPlaceholder': 'Подтвердите новый мастер-ключ',
|
||||
'cloudSync.changeKey.fillAll': 'Пожалуйста, заполните все поля',
|
||||
'cloudSync.changeKey.minLength': 'Новый мастер-ключ должен содержать не менее 8 символов',
|
||||
'cloudSync.changeKey.notMatch': 'Новые мастер-ключи не совпадают',
|
||||
'cloudSync.changeKey.incorrectCurrent': 'Неверный текущий мастер-ключ',
|
||||
'cloudSync.changeKey.failed': 'Не удалось изменить мастер-ключ',
|
||||
'cloudSync.changeKey.desc': 'Это заново зашифрует ваше хранилище. Убедитесь, что вы помните новый ключ.',
|
||||
'cloudSync.changeKey.showKeys': 'Показать ключи',
|
||||
'cloudSync.changeKey.updatedToast': 'Мастер-ключ обновлён',
|
||||
'cloudSync.changeKey.updateButton': 'Обновить ключ',
|
||||
'cloudSync.unlock.title': 'Введите мастер-ключ',
|
||||
'cloudSync.unlock.masterKey': 'Мастер-ключ',
|
||||
'cloudSync.unlock.desc':
|
||||
'Введите мастер-ключ один раз, чтобы включить зашифрованную синхронизацию. Он будет безопасно сохранён в системном keychain.',
|
||||
'cloudSync.unlock.placeholder': 'Введите мастер-ключ',
|
||||
'cloudSync.unlock.empty': 'Пожалуйста, введите мастер-ключ',
|
||||
'cloudSync.unlock.incorrect': 'Неверный мастер-ключ',
|
||||
'cloudSync.unlock.failed': 'Не удалось разблокировать хранилище',
|
||||
'cloudSync.unlock.showKey': 'Показать ключ',
|
||||
'cloudSync.unlock.notNow': 'Не сейчас',
|
||||
'cloudSync.unlock.readyToast': 'Хранилище готово',
|
||||
'cloudSync.unlock.unlockButton': 'Разблокировать',
|
||||
'cloudSync.header.vaultReady': 'Хранилище готово',
|
||||
'cloudSync.header.preparingVault': 'Подготовка хранилища...',
|
||||
'cloudSync.header.providersConnected': 'Подключено провайдеров: {count}',
|
||||
'cloudSync.githubFlow.title': 'Подключить GitHub',
|
||||
'cloudSync.githubFlow.desc': 'Скопируйте код ниже и введите его на GitHub, чтобы авторизовать NetMesh.',
|
||||
'cloudSync.githubFlow.copyCode': 'Скопировать код',
|
||||
'cloudSync.githubFlow.copied': 'Скопировано!',
|
||||
'cloudSync.githubFlow.openGitHub': 'Открыть GitHub',
|
||||
'cloudSync.githubFlow.waiting': 'Ожидание авторизации...',
|
||||
'cloudSync.conflict.title': 'Обнаружен конфликт версий',
|
||||
'cloudSync.conflict.desc': 'Выберите, какую версию сохранить',
|
||||
'cloudSync.conflict.local': 'ЛОКАЛЬНАЯ',
|
||||
'cloudSync.conflict.cloud': 'ОБЛАЧНАЯ',
|
||||
'cloudSync.conflict.detailsTitle': 'Изменённые данные',
|
||||
'cloudSync.conflict.detailsCounts': 'Локально {local} · Облако {cloud} · Конфликты {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': 'Хосты',
|
||||
'cloudSync.conflict.entity.keys': 'Ключи',
|
||||
'cloudSync.conflict.entity.identities': 'Идентификаторы',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Профили прокси',
|
||||
'cloudSync.conflict.entity.snippets': 'Сниппеты',
|
||||
'cloudSync.conflict.entity.notes': 'Заметки',
|
||||
'cloudSync.conflict.entity.noteGroups': 'Группы заметок',
|
||||
'cloudSync.conflict.entity.customGroups': 'Группы',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
|
||||
'cloudSync.conflict.entity.settings': 'Настройки',
|
||||
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
|
||||
'cloudSync.conflict.useCloud': 'Скачать из облака (перезаписать локальную)',
|
||||
'cloudSync.connect.browserContinue': 'Завершите авторизацию в браузере',
|
||||
'cloudSync.connect.browserCancelled': 'Предыдущая авторизация в браузере была отменена',
|
||||
'cloudSync.connect.github.success': 'GitHub успешно подключён',
|
||||
'cloudSync.connect.github.failedTitle': 'Ошибка подключения GitHub',
|
||||
'cloudSync.connect.github.timeout': 'Время подключения к GitHub истекло. Проверьте сеть или настройки прокси.',
|
||||
'cloudSync.connect.github.networkError': 'Не удалось связаться с GitHub. Проверьте сеть или настройки прокси.',
|
||||
'cloudSync.connect.google.failedTitle': 'Ошибка подключения Google',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'Ошибка подключения OneDrive',
|
||||
'cloudSync.sync.success': 'Синхронизировано с {provider}',
|
||||
'cloudSync.sync.failed': 'Синхронизация не удалась',
|
||||
'cloudSync.sync.failedTitle': 'Синхронизация не удалась',
|
||||
'cloudSync.sync.errorTitle': 'Ошибка синхронизации',
|
||||
'cloudSync.resolve.downloaded': 'Скачаны данные из облака',
|
||||
'cloudSync.resolve.uploaded': 'Загружены локальные данные',
|
||||
'cloudSync.resolve.failedTitle': 'Не удалось разрешить конфликт',
|
||||
'cloudSync.clearLocal.title': 'Очистить локальные данные',
|
||||
'cloudSync.clearLocal.desc': 'Сбросить локальную версию и историю синхронизации. При следующей синхронизации данные будут скачаны из облака.',
|
||||
'cloudSync.clearLocal.button': 'Очистить',
|
||||
'cloudSync.clearLocal.dialog.title': 'Очистить локальные данные хранилища?',
|
||||
'cloudSync.clearLocal.dialog.desc': 'Локальная версия будет сброшена до 0, а история синхронизации очищена. При следующей синхронизации данные будут скачаны из облака и заменят локальные.',
|
||||
'cloudSync.clearLocal.dialog.cancel': 'Отмена',
|
||||
'cloudSync.clearLocal.dialog.confirm': 'Очистить локальные данные',
|
||||
'cloudSync.clearLocal.toast.title': 'Локальные данные очищены',
|
||||
'cloudSync.clearLocal.toast.desc': 'Локальная версия сброшена до 0. Выполните синхронизацию для загрузки из облака.',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': 'Ключ',
|
||||
'keychain.filter.certificate': 'Сертификат',
|
||||
'keychain.action.generateKey': 'Создать ключ',
|
||||
'keychain.action.importKey': 'Импорт. ключ',
|
||||
'keychain.action.newIdentity': 'Новый ид-катор',
|
||||
'keychain.action.importCertificate': 'Импорт. сертификат',
|
||||
'keychain.view.grid': 'Сетка',
|
||||
'keychain.view.list': 'Список',
|
||||
'keychain.section.keys': 'Ключи',
|
||||
'keychain.section.identities': 'Идентификаторы',
|
||||
'keychain.count.items': '{count} запис(ей)',
|
||||
'keychain.empty.title': 'Настройте свои ключи',
|
||||
'keychain.empty.desc': 'Импортируйте или создайте SSH-ключи для безопасной аутентификации.',
|
||||
'keychain.panel.generateKey': 'Сгенерировать ключ',
|
||||
'keychain.panel.newKey': 'Новый ключ',
|
||||
'keychain.panel.keyDetails': 'Сведения о ключе',
|
||||
'keychain.panel.editKey': 'Редактировать ключ',
|
||||
'keychain.panel.editIdentity': 'Редактировать идентификатор',
|
||||
'keychain.panel.newIdentity': 'Новый идентификатор',
|
||||
'keychain.panel.keyExport': 'Экспорт ключа',
|
||||
'keychain.validation.labelRequired': 'Пожалуйста, введите метку для ключа',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'Метка и приватный ключ обязательны',
|
||||
'keychain.validation.labelAndUsernameRequired': 'Метка и имя пользователя обязательны',
|
||||
'keychain.error.generationUnavailable': 'Генератор ключей не работает - пожалуйста, убедитесь, что приложение работает в Electron',
|
||||
'keychain.error.generateKeyPairFailed': 'Не удалось сгенерировать пару ключей',
|
||||
'keychain.error.generateKeyFailed': 'Не удалось сгенерировать ключ',
|
||||
'keychain.error.keyGenerationTitle': 'Генерация ключа',
|
||||
'keychain.export.exportTo': 'Экспортировать в *',
|
||||
'keychain.export.selectHost': 'Выберите хост',
|
||||
'keychain.export.location': 'Расположение ~ $1 *',
|
||||
'keychain.export.filename': 'Имя файла ~ $2 *',
|
||||
'keychain.export.note': 'Экспорт ключей сейчас поддерживается только в системах {unix}. Используйте раздел {advanced} для настройки скрипта экспорта.',
|
||||
'keychain.export.script': 'Скрипт *',
|
||||
'keychain.export.scriptPlaceholder': 'Скрипт экспорта...',
|
||||
'keychain.export.missingCredentials': 'У хоста нет сохранённого пароля или ключа. Сначала добавьте в хост учётные данные с паролем.',
|
||||
'keychain.export.successTitle': 'Экспорт выполнен успешно',
|
||||
'keychain.export.successMessage': 'Публичный ключ экспортирован и привязан к {host}',
|
||||
'keychain.export.failedTitle': 'Ошибка экспорта',
|
||||
'keychain.export.failedMessage': 'Не удалось экспортировать ключ: {error}',
|
||||
'keychain.export.failedPrefix': 'Ошибка экспорта: {error}',
|
||||
'keychain.export.exitCode': 'Команда завершилась с кодом {code}',
|
||||
'keychain.export.exporting': 'Экспорт...',
|
||||
'keychain.export.exportAndAttach': 'Экспортировать и привязать',
|
||||
'keychain.export.title': 'Экспорт ключа',
|
||||
'keychain.export.exportToRequired': 'Экспортировать в *',
|
||||
'keychain.export.selectHostPlaceholder': 'Выберите хост...',
|
||||
'keychain.export.locationLabel': 'Расположение ~ $1 *',
|
||||
'keychain.export.filenameLabel': 'Имя файла ~ $2 *',
|
||||
'keychain.export.advanced': 'Дополнительно',
|
||||
'keychain.export.note.supportsOnly': 'Экспорт ключей сейчас поддерживается только в',
|
||||
'keychain.export.note.systems': 'системах.',
|
||||
'keychain.export.note.use': 'Используйте',
|
||||
'keychain.export.note.customize': 'раздел для настройки скрипта экспорта.',
|
||||
'keychain.export.scriptRequired': 'Скрипт *',
|
||||
'keychain.export.exportToHost': 'Экспортировать на хост',
|
||||
'keychain.export.failedGeneric': 'Ошибка экспорта: {message}',
|
||||
'keychain.field.label': 'Метка',
|
||||
'keychain.field.labelRequired': 'Метка *',
|
||||
'keychain.field.labelPlaceholder': 'Метка ключа',
|
||||
'keychain.field.privateKeyRequired': 'Приватный ключ *',
|
||||
'keychain.field.publicKey': 'Публичный ключ',
|
||||
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
|
||||
'keychain.generate.keyType': 'Тип ключа',
|
||||
'keychain.generate.keySize': 'Размер ключа',
|
||||
'keychain.generate.labelPlaceholder': 'Метка ключа',
|
||||
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
|
||||
'keychain.generate.savePassphrase': 'Сохранить парольную фразу',
|
||||
'keychain.generate.generate': 'Сгенерировать',
|
||||
'keychain.generate.generateSave': 'Сгенерировать и сохранить',
|
||||
'keychain.import.dropHint': 'Перетащите сюда файл ключа',
|
||||
'keychain.import.importFromFile': 'Импортировать из файла',
|
||||
'keychain.import.saveKey': 'Сохранить ключ',
|
||||
'keychain.import.importedKeyLabel': 'Импортированный ключ',
|
||||
'keychain.identity.usernameRequired': 'Имя пользователя *',
|
||||
'keychain.identity.method.passwordOnly': 'Пароль',
|
||||
'keychain.identity.summary.password': 'Пароль аутентификации',
|
||||
'keychain.identity.summary.key': 'Ключ аутентификации',
|
||||
'keychain.identity.summary.certificate': 'Сертификат аутентификации',
|
||||
'keychain.identity.summary.passwordAndKey': 'Пароль и ключ аутентификации',
|
||||
'keychain.identity.summary.passwordAndCertificate': 'Пароль и сертификат аутентификации',
|
||||
'keychain.identity.summary.none': 'Нет учётных данных',
|
||||
'keychain.identity.selectCredential': 'Выберите {kind}',
|
||||
'keychain.identity.save': 'Сохранить',
|
||||
'keychain.identity.update': 'Обновить',
|
||||
'keychain.keyDialog.newTitle': 'Новый ключ',
|
||||
'keychain.keyDialog.newDesc': 'Добавить новый SSH-ключ',
|
||||
'keychain.keyDialog.editTitle': 'Редактировать ключ',
|
||||
'keychain.keyDialog.editDesc': 'Обновить этот SSH-ключ',
|
||||
'keychain.keyDialog.updateKey': 'Обновить ключ',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Закрыть сессию',
|
||||
'tabs.closeLogViewAria': 'Закрыть просмотр журнала',
|
||||
'tabs.closePluginViewAria': 'Закрыть {title}',
|
||||
'tabs.logPrefix': 'Журнал:',
|
||||
'tabs.logLocal': 'Локальный',
|
||||
'tabs.copyTab': 'Копировать вкладку',
|
||||
'tabs.duplicateSession': 'Дублировать сессию',
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
'keychain.edit.labelRequired': 'Метка *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Метка ключа',
|
||||
'keychain.edit.privateKeyRequired': 'Приватный ключ *',
|
||||
'keychain.edit.publicKey': 'Публичный ключ',
|
||||
'keychain.edit.certificate': 'Сертификат',
|
||||
'keychain.edit.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
|
||||
'keychain.edit.filePath': 'Путь к файлу',
|
||||
'keychain.edit.keyExport': 'Экспорт ключа',
|
||||
'keychain.edit.exportToHost': 'Экспортировать на хост',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Поиск сниппетов...',
|
||||
'snippets.action.newSnippet': 'Новый сниппет',
|
||||
'snippets.action.newPackage': 'Новый пакет',
|
||||
'snippets.action.import': 'Импорт',
|
||||
'snippets.action.selectSnippets': 'Выбрать сниппеты',
|
||||
'snippets.panel.newTitle': 'Новый сниппет',
|
||||
'snippets.panel.editTitle': 'Редактировать сниппет',
|
||||
'snippets.panel.resizeWidth': 'Изменить ширину панели',
|
||||
'snippets.field.description': 'Описание действия',
|
||||
'snippets.field.descriptionPlaceholder': 'Например: проверить сетевую нагрузку',
|
||||
'snippets.field.package': 'Добавить пакет',
|
||||
'snippets.field.packagePlaceholder': 'Выберите или создайте пакет',
|
||||
'snippets.field.createPackage': 'Создать пакет',
|
||||
'snippets.field.scriptRequired': 'Скрипт *',
|
||||
'snippets.scriptEditor.expand': 'Открыть в окне',
|
||||
'snippets.scriptEditor.resize': 'Изменить высоту редактора',
|
||||
'snippets.scriptEditor.modalTitle': 'Редактировать скрипт',
|
||||
'snippets.variables.dialogTitle': 'Переменные сниппета',
|
||||
'snippets.variables.dialogDesc': 'Заполните значения для "{label}" перед запуском.',
|
||||
'snippets.variables.hint': 'Значения вставляются в скрипт как есть (без shell-экранирования).',
|
||||
'snippets.variables.preview': 'Предпросмотр',
|
||||
'snippets.variables.placeholder': 'Введите значение',
|
||||
'snippets.variables.placeholderDefault': 'По умолчанию: {value}',
|
||||
'snippets.variables.required': 'Эта переменная обязательна',
|
||||
'snippets.variables.run': 'Запустить',
|
||||
'snippets.field.variablesHelp': 'Используйте {{name}} или {{name:default}} для плейсхолдеров в скрипте.',
|
||||
'snippets.field.variablesDetected': 'Переменные',
|
||||
'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': 'История оболочки пока пуста',
|
||||
'snippets.history.emptyDesc': 'Здесь будут появляться выполненные вами команды',
|
||||
'snippets.history.loadMore': 'Загрузить ещё',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': 'Задайте метку для этого сниппета',
|
||||
'snippets.history.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'snippets.history.time.justNow': 'только что',
|
||||
'snippets.history.time.minutesAgo': '{count}м назад',
|
||||
'snippets.history.time.hoursAgo': '{count}ч назад',
|
||||
'snippets.history.time.daysAgo': '{count}д назад',
|
||||
'snippets.breadcrumb.allPackages': 'Все пакеты',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Создать сниппет',
|
||||
'snippets.empty.desc': 'Сохраняйте самые используемые команды как сниппеты, чтобы повторно использовать их в один клик.',
|
||||
'snippets.search.noResults.title': 'Нет совпадений',
|
||||
'snippets.search.noResults.desc': 'Ни один сниппет или пакет не соответствует запросу "{query}". Попробуйте другой поисковый запрос или очистите поиск для просмотра.',
|
||||
'snippets.section.packages': 'Пакеты',
|
||||
'snippets.section.snippets': 'Сниппеты',
|
||||
'snippets.package.count': '{count} сниппет(ов)',
|
||||
'snippets.commandFallback': 'Команда',
|
||||
'snippets.view.grid': 'Сетка',
|
||||
'snippets.view.list': 'Список',
|
||||
'snippets.selection.selected': 'Выбрано: {count}',
|
||||
'snippets.selection.selectVisible': 'Выбрать видимые',
|
||||
'snippets.selection.deselectAll': 'Снять выбор',
|
||||
'snippets.selection.exportSelected': 'Экспорт выбранных ({count})',
|
||||
'snippets.selection.deleteSelected': 'Удалить ({count})',
|
||||
'snippets.selection.deleteConfirmTitle': 'Удалить выбранные элементы ({count})?',
|
||||
'snippets.selection.deleteConfirmDesc': 'Выбранные элементы будут удалены без возможности восстановления.',
|
||||
'snippets.selection.deleteSuccess': 'Удалено выбранных элементов: {count}.',
|
||||
'snippets.export.snippet': 'Экспорт сниппета',
|
||||
'snippets.export.package': 'Экспорт пакета',
|
||||
'snippets.export.toast.empty': 'Нет сниппетов для экспорта.',
|
||||
'snippets.export.toast.successTitle': 'Экспорт готов',
|
||||
'snippets.export.toast.success': 'Экспортировано сниппетов: {count}.',
|
||||
'snippets.import.toast.empty': 'Не найдено сниппетов для импорта.',
|
||||
'snippets.import.toast.failedTitle': 'Импорт не удался',
|
||||
'snippets.import.toast.invalidDesc': 'Это невалидный файл сниппетов NetMesh.',
|
||||
'snippets.import.toast.successTitle': 'Импорт завершён',
|
||||
'snippets.import.toast.summary': 'Импортировано: {imported}, перезаписано: {overwritten}, пропущено: {skipped}.',
|
||||
'snippets.import.modal.title': 'Импорт сниппетов',
|
||||
'snippets.import.modal.desc': 'Выберите один или несколько JSON-файлов сниппетов NetMesh. NetMesh сначала покажет результат разбора и ничего не импортирует без подтверждения.',
|
||||
'snippets.import.modal.exampleTitle': 'Пример JSON',
|
||||
'snippets.import.modal.noFile': 'Файл ещё не выбран. Используйте пример ниже или обычный JSON-массив сниппетов, затем выберите один или несколько файлов.',
|
||||
'snippets.import.modal.chooseFile': 'Выбрать файл',
|
||||
'snippets.import.modal.downloadExamples': 'Скачать примеры',
|
||||
'snippets.import.modal.multipleFiles': 'Выбрано файлов: {count}',
|
||||
'snippets.import.modal.parsedSummary': '{files} файл(ов), {total} сниппет(ов), {packages} пакет(ов), повторяющихся команд: {conflicts}. Привязки к хостам игнорируются.',
|
||||
'snippets.import.modal.confirm': 'Подтвердить импорт',
|
||||
'snippets.import.conflict.title': 'Импортировать сниппеты?',
|
||||
'snippets.import.conflict.desc': 'Файл «{file}» содержит {total} сниппет(ов), из них повторяющихся команд: {conflicts}.',
|
||||
'snippets.import.conflict.hostBindingsNote': 'Привязки сниппетов к хостам не импортируются и не экспортируются.',
|
||||
'snippets.import.conflict.skip': 'Пропустить повторы',
|
||||
'snippets.import.conflict.overwrite': 'Перезаписать повторы',
|
||||
'snippets.packageDialog.title': 'Новый пакет',
|
||||
'snippets.packageDialog.parent': 'Родитель: {parent}',
|
||||
'snippets.packageDialog.root': 'Корень',
|
||||
'snippets.packageDialog.placeholder': 'например, ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Используйте "/" для создания вложенных пакетов.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Переименовать пакет',
|
||||
'snippets.renameDialog.currentPath': 'Текущий путь: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Введите новое имя',
|
||||
'snippets.renameDialog.error.empty': 'Имя пакета не может быть пустым',
|
||||
'snippets.renameDialog.error.duplicate': 'Пакет с таким именем уже существует',
|
||||
'snippets.renameDialog.error.invalidChars': 'Имя пакета может содержать только буквы, цифры, дефисы и подчёркивания',
|
||||
|
||||
'snippets.field.noAutoRun': 'Только вставить (не выполнять автоматически)',
|
||||
'snippets.field.multiLineRunMode': 'Многострочный запуск',
|
||||
'snippets.field.multiLineRunMode.paste': 'Отправить сразу',
|
||||
'snippets.field.multiLineRunMode.lineDelay': 'Отправлять построчно',
|
||||
'snippets.field.multiLineRunModeHint': 'Построчный режим подходит для логинов с подсказками и макросов устройств.',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Сочетание клавиш',
|
||||
'snippets.shortkey.placeholder': 'Нажмите, чтобы задать сочетание',
|
||||
'snippets.shortkey.recording': 'Нажмите сочетание клавиш...',
|
||||
'snippets.shortkey.hint': 'Нажмите это сочетание в терминале, чтобы быстро отправить команду.',
|
||||
'snippets.shortkey.clear': 'Очистить сочетание',
|
||||
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с «{name}»',
|
||||
'snippets.shortkey.error.snippetConflict': 'Это сочетание уже используется сниппетом: {name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Серийный',
|
||||
'serial.modal.title': 'Подключение к последовательному порту',
|
||||
'serial.modal.desc': 'Настройте параметры подключения к последовательному порту',
|
||||
'serial.field.port': 'Последовательный порт',
|
||||
'serial.field.selectPort': 'Выберите порт...',
|
||||
'serial.field.baudRate': 'Скорость передачи',
|
||||
'serial.field.dataBits': 'Биты данных',
|
||||
'serial.field.stopBits': 'Стоп-биты',
|
||||
'serial.field.stopBits15Warning': 'Стоп-биты 1,5 могут поддерживаться не всеми устройствами Windows',
|
||||
'serial.field.parity': 'Чётность',
|
||||
'serial.field.flowControl': 'Управление потоком',
|
||||
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
|
||||
'serial.field.customPort': 'Путь к пользовательскому порту',
|
||||
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
|
||||
'serial.type.hardware': 'Аппаратный',
|
||||
'serial.type.pseudo': 'Псевдотерминал',
|
||||
'serial.type.custom': 'Пользовательский',
|
||||
'serial.parity.none': 'Нет',
|
||||
'serial.parity.even': 'Чётная',
|
||||
'serial.parity.odd': 'Нечётная',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': 'Нет',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (программный)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (аппаратный)',
|
||||
'serial.field.localEcho': 'Принудительное локальное эхо',
|
||||
'serial.field.localEchoDesc': 'Локально отображать вводимые символы (для устройств без удалённого эха)',
|
||||
'serial.field.lineMode': 'Построчный режим',
|
||||
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
|
||||
'serial.field.backspaceBehavior': 'Клавиша Backspace',
|
||||
'serial.field.backspaceBehaviorDesc': 'Используйте Ctrl+H для сетевых устройств, которые не реагируют на код Backspace по умолчанию.',
|
||||
'serial.backspace.default': 'По умолчанию (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Кодировка',
|
||||
'serial.connectionError': 'Не удалось подключиться к последовательному порту',
|
||||
'serial.field.baudRatePlaceholder': 'Выберите или введите скорость...',
|
||||
'serial.field.baudRateEmpty': 'Введите пользовательскую скорость передачи',
|
||||
'serial.field.customBaudRate': 'Используется пользовательская скорость передачи',
|
||||
'serial.field.saveConfig': 'Сохранить конфигурацию',
|
||||
'serial.field.saveConfigDesc': 'Сохраните эту последовательную конфигурацию в хостах для быстрого доступа',
|
||||
'serial.field.configLabel': 'Имя конфигурации',
|
||||
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
|
||||
'serial.connectAndSave': 'Подключить и сохранить',
|
||||
'serial.edit.title': 'Настройки последовательного порта',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Требуется аутентификация',
|
||||
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.response': 'Ответ',
|
||||
'keyboard.interactive.enterCode': 'Введите код подтверждения',
|
||||
'keyboard.interactive.enterResponse': 'Введите ответ',
|
||||
'keyboard.interactive.submit': 'Отправить',
|
||||
'keyboard.interactive.verifying': 'Проверка...',
|
||||
'keyboard.interactive.savePassword': 'Сохранить пароль',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Парольная фраза SSH-ключа',
|
||||
'passphrase.desc': 'Введите парольную фразу для {keyName}',
|
||||
'passphrase.descWithHost': 'Введите парольную фразу для {keyName}, чтобы подключиться к {hostname}',
|
||||
'passphrase.label': 'Парольная фраза',
|
||||
'passphrase.keyPath': 'Ключ',
|
||||
'passphrase.unlock': 'Разблокировать',
|
||||
'passphrase.unlocking': 'Разблокировка...',
|
||||
'passphrase.skip': 'Пропустить',
|
||||
'passphrase.remember': 'Запомнить эту парольную фразу',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Перенос строк',
|
||||
'sftp.editor.maximize': 'Развернуть',
|
||||
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
|
||||
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
|
||||
'sftp.editor.discardChanges': 'Отбросить',
|
||||
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
|
||||
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
|
||||
|
||||
};
|
||||
1097
application/i18n/locales/ru/vault.ts
Normal file
1097
application/i18n/locales/ru/vault.ts
Normal file
File diff suppressed because it is too large
Load Diff
28
application/i18n/locales/serverStatsLatency.test.ts
Normal file
28
application/i18n/locales/serverStatsLatency.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { enSystemManagerMessages } from "./en/systemManager";
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { ruSystemManagerMessages } from "./ru/systemManager";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { esSystemManagerMessages } from "./es/systemManager";
|
||||
import { esTerminalMessages } from "./es/terminal";
|
||||
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
|
||||
import { zhCNVaultMessages } from "./zh-CN/vault";
|
||||
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
|
||||
import { zhTWVaultMessages } from "./zh-TW/vault";
|
||||
|
||||
test("SSH network latency is explicit in every locale and UI surface", () => {
|
||||
const labels = [
|
||||
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
|
||||
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
|
||||
[esTerminalMessages, esSystemManagerMessages, "Latencia de red SSH"],
|
||||
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
|
||||
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
|
||||
] as const;
|
||||
|
||||
for (const [terminalMessages, systemManagerMessages, expected] of labels) {
|
||||
assert.equal(terminalMessages["terminal.serverStats.latency"], expected);
|
||||
assert.equal(systemManagerMessages["systemManager.overview.latency"], expected);
|
||||
}
|
||||
});
|
||||
203
application/i18n/locales/settingsLocales.test.ts
Normal file
203
application/i18n/locales/settingsLocales.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import en from "./en.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include workspace focus indicator labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.section.workspaceFocus",
|
||||
"settings.terminal.workspaceFocus.style",
|
||||
"settings.terminal.workspaceFocus.style.desc",
|
||||
"settings.terminal.workspaceFocus.dim",
|
||||
"settings.terminal.workspaceFocus.border",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include network proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.system.networkProxy.title",
|
||||
"settings.system.networkProxy.description",
|
||||
"settings.system.networkProxy.mode",
|
||||
"settings.system.networkProxy.mode.system",
|
||||
"settings.system.networkProxy.mode.direct",
|
||||
"settings.system.networkProxy.mode.custom",
|
||||
"settings.system.networkProxy.url",
|
||||
"settings.system.networkProxy.url.placeholder",
|
||||
"settings.system.networkProxy.url.desc",
|
||||
"settings.system.networkProxy.bypass",
|
||||
"settings.system.networkProxy.bypass.placeholder",
|
||||
"settings.system.networkProxy.bypass.desc",
|
||||
"settings.system.networkProxy.hint",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing network proxy labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include OSC desktop notification labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.behavior.oscNotifications",
|
||||
"settings.terminal.behavior.oscNotifications.desc",
|
||||
"settings.terminal.behavior.oscNotifications.off",
|
||||
"settings.terminal.behavior.oscNotifications.unfocused",
|
||||
"settings.terminal.behavior.oscNotifications.always",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing OSC notification labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
"settings.terminal.font.weight.extraLight",
|
||||
"settings.terminal.font.weight.light",
|
||||
"settings.terminal.font.weight.normal",
|
||||
"settings.terminal.font.weight.medium",
|
||||
"settings.terminal.font.weight.semiBold",
|
||||
"settings.terminal.font.weight.bold",
|
||||
"settings.terminal.font.weight.extraBold",
|
||||
"settings.terminal.font.weight.black",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("all app lock strings are translated in every supported locale", () => {
|
||||
const keys = [
|
||||
"appLock.title",
|
||||
"appLock.reason.default",
|
||||
"appLock.reason.startup",
|
||||
"appLock.reason.idle",
|
||||
"appLock.reason.manual",
|
||||
"appLock.passwordLabel",
|
||||
"appLock.passwordPlaceholder",
|
||||
"appLock.unlock",
|
||||
"appLock.unlocking",
|
||||
"appLock.error.emptyPassword",
|
||||
"appLock.error.incorrectPassword",
|
||||
"appLock.systemUnlock.unlockWith",
|
||||
"appLock.systemUnlock.error",
|
||||
"appLock.logoLabel",
|
||||
"appLock.reset.title",
|
||||
"appLock.reset.description",
|
||||
"appLock.reset.cancel",
|
||||
"appLock.reset.confirm",
|
||||
"appLock.reset.resetting",
|
||||
"appLock.reset.error",
|
||||
"topTabs.lockApp",
|
||||
"settings.appLock.title",
|
||||
"settings.appLock.description",
|
||||
"settings.appLock.enable",
|
||||
"settings.appLock.enableDesc",
|
||||
"settings.appLock.timeout",
|
||||
"settings.appLock.timeoutDesc",
|
||||
"settings.appLock.timeout.0",
|
||||
"settings.appLock.timeout.1",
|
||||
"settings.appLock.timeout.5",
|
||||
"settings.appLock.timeout.15",
|
||||
"settings.appLock.timeout.30",
|
||||
"settings.appLock.timeout.60",
|
||||
"settings.appLock.systemUnlock.label",
|
||||
"settings.appLock.systemUnlock.desc",
|
||||
"settings.appLock.systemUnlock.unavailableDesc",
|
||||
"settings.appLock.systemUnlock.unavailable",
|
||||
"settings.appLock.systemUnlock.locked",
|
||||
"settings.appLock.systemUnlock.autoPrompt.label",
|
||||
"settings.appLock.systemUnlock.autoPrompt.desc",
|
||||
"settings.appLock.currentPassword",
|
||||
"settings.appLock.currentPasswordPlaceholder",
|
||||
"settings.appLock.newPassword",
|
||||
"settings.appLock.newPasswordPlaceholder",
|
||||
"settings.appLock.confirmPassword",
|
||||
"settings.appLock.confirmPasswordPlaceholder",
|
||||
"settings.appLock.savePassword",
|
||||
"settings.appLock.savingPassword",
|
||||
"settings.appLock.passwordSet",
|
||||
"settings.appLock.replacePassword",
|
||||
"settings.appLock.enableAfterPassword",
|
||||
"settings.appLock.localOnlyHint",
|
||||
"settings.appLock.validation.currentRequired",
|
||||
"settings.appLock.validation.newRequired",
|
||||
"settings.appLock.validation.confirmRequired",
|
||||
"settings.appLock.validation.mismatch",
|
||||
"settings.appLock.validation.incorrect",
|
||||
];
|
||||
|
||||
for (const locale of APP_LOCK_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing app lock labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include host icon labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.icon.title",
|
||||
"hostDetails.icon.desc",
|
||||
"hostDetails.icon.mode.auto",
|
||||
"hostDetails.icon.mode.custom",
|
||||
"hostDetails.icon.reset",
|
||||
"hostDetails.icon.showLibrary",
|
||||
"hostDetails.icon.hideLibrary",
|
||||
"hostDetails.icon.autoUsesDistro",
|
||||
"hostDetails.icon.customOverridesDistro",
|
||||
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
|
||||
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include interactive authentication labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.auth.mfaFirst",
|
||||
"hostDetails.auth.mfaFirst.desc",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing interactive authentication labels`);
|
||||
}
|
||||
});
|
||||
35
application/i18n/locales/sftpConflictLocales.test.ts
Normal file
35
application/i18n/locales/sftpConflictLocales.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { enVaultMessages } from './en/vault.ts';
|
||||
import { esVaultMessages } from './es/vault.ts';
|
||||
import { ruVaultMessages } from './ru/vault.ts';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault.ts';
|
||||
import { zhTWVaultMessages } from './zh-TW/vault.ts';
|
||||
|
||||
const FOLDER_CONFLICT_KEYS = [
|
||||
'sftp.conflict.folderTitle',
|
||||
'sftp.conflict.folderDesc',
|
||||
'sftp.conflict.folderFileDesc',
|
||||
'sftp.conflict.folderSymlinkDesc',
|
||||
'sftp.conflict.folderUnknownDesc',
|
||||
'sftp.conflict.folderMergeHint',
|
||||
'sftp.conflict.folderReplaceWarning',
|
||||
] as const;
|
||||
|
||||
test('folder conflict safety copy exists in every supported locale', () => {
|
||||
const locales = {
|
||||
en: enVaultMessages,
|
||||
es: esVaultMessages,
|
||||
ru: ruVaultMessages,
|
||||
'zh-CN': zhCNVaultMessages,
|
||||
'zh-TW': zhTWVaultMessages,
|
||||
};
|
||||
|
||||
for (const [locale, messages] of Object.entries(locales)) {
|
||||
for (const key of FOLDER_CONFLICT_KEYS) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key]?.trim(), '', `${locale} has an empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
44
application/i18n/locales/snippetBulkDeleteLocales.test.ts
Normal file
44
application/i18n/locales/snippetBulkDeleteLocales.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'snippets.selection.deleteSelected',
|
||||
'snippets.selection.deleteConfirmTitle',
|
||||
'snippets.selection.deleteConfirmDesc',
|
||||
'snippets.selection.deleteSuccess',
|
||||
] as const;
|
||||
|
||||
test('snippet bulk-delete copy exists in every locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing snippet bulk-delete copy`);
|
||||
}
|
||||
});
|
||||
|
||||
test('snippet shortkey system-conflict copy names the conflicting action', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const text = messages['snippets.shortkey.error.systemConflict'];
|
||||
assert.match(
|
||||
text ?? '',
|
||||
/\{name\}/,
|
||||
`${locale} system-conflict copy should include {name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('English bulk-delete copy is entity-neutral and grammatical for one item', () => {
|
||||
assert.equal(
|
||||
en['snippets.selection.deleteConfirmTitle'].replace('{count}', '1'),
|
||||
'Delete selected items (1)?',
|
||||
);
|
||||
assert.equal(
|
||||
en['snippets.selection.deleteSuccess'].replace('{count}', '1'),
|
||||
'Deleted selected items: 1.',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en';
|
||||
import ru from './ru';
|
||||
import es from './es';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const keys = [
|
||||
'terminal.layer.hostTree.newHost',
|
||||
'terminal.layer.hostTree.newHostInGroup',
|
||||
'terminal.layer.hostTree.editHost',
|
||||
'terminal.layer.hostTree.hostSavedNextConnection',
|
||||
] as const;
|
||||
|
||||
test('terminal host management strings exist in every shipped locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has an empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
57
application/i18n/locales/terminalInlineImageLocales.test.ts
Normal file
57
application/i18n/locales/terminalInlineImageLocales.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const INLINE_IMAGE_KEYS = [
|
||||
"settings.terminal.section.inlineImages",
|
||||
"settings.terminal.inlineImages.enabled",
|
||||
"settings.terminal.inlineImages.enabled.desc",
|
||||
"settings.terminal.inlineImages.kitty",
|
||||
"settings.terminal.inlineImages.kitty.desc",
|
||||
"settings.terminal.inlineImages.sixel",
|
||||
"settings.terminal.inlineImages.sixel.desc",
|
||||
"settings.terminal.inlineImages.iip",
|
||||
"settings.terminal.inlineImages.iip.desc",
|
||||
"settings.terminal.inlineImages.storageLimit",
|
||||
"settings.terminal.inlineImages.storageLimit.desc",
|
||||
"settings.terminal.inlineImages.maxMegapixels",
|
||||
"settings.terminal.inlineImages.maxMegapixels.desc",
|
||||
"settings.terminal.inlineImages.sequenceLimit",
|
||||
"settings.terminal.inlineImages.sequenceLimit.desc",
|
||||
"settings.terminal.inlineImages.unit.mb",
|
||||
"settings.terminal.inlineImages.unit.megapixels",
|
||||
"settings.terminal.inlineImages.hibernateNote",
|
||||
];
|
||||
|
||||
const LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("every locale ships the inline image settings strings", () => {
|
||||
for (const locale of LOCALES) {
|
||||
const missing = INLINE_IMAGE_KEYS.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing inline image settings labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("inline image strings are actually translated, not copied from English", () => {
|
||||
const translatedKeys = INLINE_IMAGE_KEYS.filter(
|
||||
(key) => !key.startsWith("settings.terminal.inlineImages.unit.")
|
||||
&& key !== "settings.terminal.inlineImages.sixel",
|
||||
);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
if (locale.name === "en") continue;
|
||||
const untranslated = translatedKeys.filter((key) => locale.messages[key] === en[key]);
|
||||
assert.deepEqual(untranslated, [], `${locale.name} still uses the English string`);
|
||||
}
|
||||
});
|
||||
29
application/i18n/locales/terminalOsc7Locales.test.ts
Normal file
29
application/i18n/locales/terminalOsc7Locales.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
|
||||
const osc7Keys = [
|
||||
"terminal.toolbar.configureOsc7",
|
||||
"terminal.osc7Setup.title",
|
||||
"terminal.osc7Setup.desc",
|
||||
"terminal.osc7Setup.targets",
|
||||
"terminal.osc7Setup.command",
|
||||
"terminal.osc7Setup.run",
|
||||
"terminal.osc7Setup.running",
|
||||
"terminal.osc7Setup.configured",
|
||||
"terminal.osc7Setup.failed",
|
||||
"terminal.osc7Setup.sent",
|
||||
] as const;
|
||||
|
||||
test("OSC 7 setup copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
|
||||
for (const key of osc7Keys) {
|
||||
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
31
application/i18n/locales/terminalReconnectLocales.test.ts
Normal file
31
application/i18n/locales/terminalReconnectLocales.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { esTerminalMessages } from "./es/terminal";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { zhCNTerminalMessages } from "./zh-CN/terminal";
|
||||
import { zhTWTerminalMessages } from "./zh-TW/terminal";
|
||||
|
||||
test("terminal reconnect notices are localized in every bundled language", () => {
|
||||
const messages = [
|
||||
enTerminalMessages,
|
||||
esTerminalMessages,
|
||||
ruTerminalMessages,
|
||||
zhCNTerminalMessages,
|
||||
zhTWTerminalMessages,
|
||||
];
|
||||
const keys = [
|
||||
"terminal.progress.enterReconnectHint",
|
||||
"terminal.progress.reconnecting",
|
||||
"terminal.progress.autoReconnectScheduled",
|
||||
"terminal.progress.autoReconnectAttempt",
|
||||
];
|
||||
|
||||
for (const locale of messages) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof locale[key], "string", `missing ${key}`);
|
||||
assert.notEqual(locale[key]?.trim(), "", `empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
1
application/i18n/locales/types.ts
Normal file
1
application/i18n/locales/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Messages = Record<string, string>;
|
||||
37
application/i18n/locales/vaultBulkImportLocales.test.ts
Normal file
37
application/i18n/locales/vaultBulkImportLocales.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const KEYS = [
|
||||
"vault.hosts.selectedSummary",
|
||||
"vault.groups.selectedCount",
|
||||
"vault.groups.deleteMultiple.success",
|
||||
"vault.groups.deleteDialog.bulkTitle",
|
||||
"vault.groups.deleteDialog.bulkDesc",
|
||||
"vault.groups.deleteDialog.bulkDeleteHosts",
|
||||
"vault.import.destination.title",
|
||||
"vault.import.destination.settings",
|
||||
"vault.import.destination.done",
|
||||
"vault.import.securecrt.folder",
|
||||
"vault.import.securecrt.promptTitle",
|
||||
"vault.import.securecrt.promptDesc",
|
||||
"vault.import.progress.fileCount",
|
||||
"vault.import.progress.persistFailed",
|
||||
"vault.import.progress.rollbackFailed",
|
||||
"vault.import.sshConfig.managedDestinationHint",
|
||||
"vault.import.mobaxterm.masterPassword",
|
||||
"vault.import.mobaxterm.masterPasswordPlaceholder",
|
||||
"vault.import.mobaxterm.masterPasswordHint",
|
||||
] as const;
|
||||
|
||||
test("bulk vault import and group-selection copy exists in every locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing bulk vault labels`);
|
||||
}
|
||||
});
|
||||
20
application/i18n/locales/zh-CN.ts
Normal file
20
application/i18n/locales/zh-CN.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Messages } from './types';
|
||||
import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
import { zhCNTerminalMessages } from './zh-CN/terminal';
|
||||
import { zhCNAiMessages } from './zh-CN/ai';
|
||||
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
|
||||
import { zhCNScriptsMessages } from './zh-CN/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhCN: Messages = {
|
||||
...zhCNCoreMessages,
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
...zhCNAiMessages,
|
||||
...zhCnSystemManagerMessages,
|
||||
...zhCNScriptsMessages,
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
672
application/i18n/locales/zh-CN/ai.ts
Normal file
672
application/i18n/locales/zh-CN/ai.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.chat.compactingContext': '正在压缩较早的上下文…',
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
|
||||
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent 活动',
|
||||
'ai.chat.activity.plan': '计划',
|
||||
'ai.chat.activity.webSearch': '网页搜索',
|
||||
'ai.chat.activity.fileChanges': '文件变更',
|
||||
'ai.chat.activity.status.running': '进行中',
|
||||
'ai.chat.activity.status.completed': '已完成',
|
||||
'ai.chat.activity.status.failed': '失败',
|
||||
'ai.chat.activity.file.add': '新增',
|
||||
'ai.chat.activity.file.update': '修改',
|
||||
'ai.chat.activity.file.delete': '删除',
|
||||
'ai.chat.activity.usage': 'Token',
|
||||
'ai.chat.activity.usage.input': '输入',
|
||||
'ai.chat.activity.usage.output': '输出',
|
||||
'ai.chat.activity.usage.cached': '缓存',
|
||||
'ai.chat.activity.usage.reasoning': '推理',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
'ai.agents': 'Agent',
|
||||
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
|
||||
'ai.providers.add': '添加提供商',
|
||||
'ai.providers.active': '活跃',
|
||||
'ai.providers.apiKeyConfigured': 'API Key 已配置',
|
||||
'ai.providers.noApiKey': '未设置 API Key',
|
||||
'ai.providers.configure': '配置',
|
||||
'ai.providers.remove': '移除',
|
||||
'ai.providers.name': '显示名称',
|
||||
'ai.providers.name.placeholder': '例如 我的提供商',
|
||||
'ai.providers.style': '协议风格',
|
||||
'ai.providers.style.anthropic': 'Anthropic 兼容',
|
||||
'ai.providers.style.openai': 'OpenAI 兼容',
|
||||
'ai.providers.style.google': 'Google 兼容',
|
||||
'ai.providers.style.inherited': '默认',
|
||||
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
|
||||
'ai.providers.openaiApi': 'OpenAI 请求格式',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': '默认 Chat Completions,兼容大多数 OpenAI 兼容接口。部分中转站在 Responses(/v1/responses)上缓存命中率更高。',
|
||||
'ai.providers.icon.change': '修改图标',
|
||||
'ai.providers.icon.upload': '上传图片',
|
||||
'ai.providers.icon.reset': '恢复默认',
|
||||
'ai.providers.icon.close': '收起',
|
||||
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
|
||||
'ai.providers.icon.errorType': '请选择图片文件。',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'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.baseUrl.ollamaHelp': '本地 Ollama:http://localhost:11434/v1(无需 API Key)。Ollama Cloud:https://ollama.com/v1,并填写 Cloud API Key。',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': '上下文窗口',
|
||||
'ai.providers.contextWindow.placeholder': '例如 128000',
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,NetMesh 会使用安全默认值。',
|
||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.test': '检测',
|
||||
'ai.providers.test.testing': '检测中…',
|
||||
'ai.providers.test.ok': '连接正常({latency} ms)',
|
||||
'ai.providers.test.warn': '已连通,但响应不完整({latency} ms)',
|
||||
'ai.providers.test.warnSlow': '已连通,但较慢({latency} ms)',
|
||||
'ai.providers.test.error': '检测失败({detail})',
|
||||
'ai.providers.test.missingBaseUrl': '请先填写 Base URL',
|
||||
'ai.providers.test.missingApiKey': '请先填写 API Key',
|
||||
'ai.providers.test.unavailable': '当前环境无法进行连接检测',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
'ai.providers.noMatchingModels': '没有匹配的模型',
|
||||
'ai.providers.clickToLoadModels': '点击加载模型',
|
||||
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
|
||||
'ai.providers.advancedParams': '高级参数',
|
||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '提供商默认',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '接入 OpenAI Codex。可以在这里登录 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.appServer.title': '使用 Codex App Server',
|
||||
'ai.codex.appServer.experimental': '实验性',
|
||||
'ai.codex.appServer.description': '使用持久化 Codex 协议,提供原生审批、沙箱控制、动态模型和对话中提问。默认仍使用 SDK。',
|
||||
'ai.codex.appServer.checking': '正在检查 App Server 支持…',
|
||||
'ai.codex.appServer.available': '当前 Codex CLI 支持 App Server。',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'Codex 动态模型目录暂不可用,当前将使用内置模型列表。',
|
||||
'ai.codex.appServer.approval.allowSession': '本会话允许',
|
||||
'ai.codex.appServer.userInput.title': 'Codex 需要你的输入',
|
||||
'ai.codex.appServer.userInput.description': '回答以下问题以继续当前任务。',
|
||||
'ai.codex.appServer.userInput.other': '输入其他答案',
|
||||
'ai.codex.appServer.userInput.autoResolve': '若未及时回答,Codex 将自动继续。',
|
||||
'ai.codex.appServer.userInput.skip': '跳过',
|
||||
'ai.codex.appServer.userInput.submit': '继续',
|
||||
'ai.codex.steer.addInstruction': '补充指令',
|
||||
'ai.codex.steer.sending': '正在补充指令…',
|
||||
'ai.codex.steer.placeholder': '在 Codex 工作时补充指令…',
|
||||
'ai.codex.steer.notSteerableReview': '当前 Codex 审查任务无法接收补充指令,草稿已保留。',
|
||||
'ai.codex.steer.notSteerableCompact': '当前 Codex 压缩任务无法接收补充指令,草稿已保留。',
|
||||
'ai.codex.steer.busy': '已有一条补充指令正在发送给 Codex。',
|
||||
'ai.codex.steer.inactive': '当前 Codex 任务已经结束,草稿已保留。',
|
||||
'ai.codex.steer.unsupported': '运行中补充指令仅支持 Codex App Server。',
|
||||
'ai.codex.steer.failed': 'Codex 未能接收补充指令,草稿已保留。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
|
||||
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
|
||||
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
|
||||
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 NetMesh),否则 Codex 无法鉴权。',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
|
||||
'ai.codex.check': '检查',
|
||||
'ai.codex.resetPath': '重置',
|
||||
'ai.codex.openLogin': '打开登录',
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
'ai.claude.path': '路径:',
|
||||
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.configSection': '认证与配置(可选)',
|
||||
'ai.claude.configDir': '配置目录',
|
||||
'ai.claude.configDir.placeholder': '~/.claude(留空用默认)',
|
||||
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
|
||||
'ai.claude.settings': 'Settings 文件',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json(路径,或内联 {"model":"..."})',
|
||||
'ai.claude.settings.hint': '可选。settings.json 路径或内联 JSON,作为 SDK 的 `settings` 传入。与上面的「配置目录」互补且独立(叠加合并,不是替换)。',
|
||||
'ai.claude.envVars': '环境变量',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
|
||||
'ai.claude.check': '检查',
|
||||
'ai.claude.resetPath': '重置',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.detecting': '检测中...',
|
||||
'ai.copilot.detected': '已检测到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
'ai.copilot.path': '路径:',
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
'ai.copilot.resetPath': '重置',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': '使用 Cursor SDK 或本地 Agent CLI 登录。',
|
||||
'ai.cursor.detecting': '检测中...',
|
||||
'ai.cursor.detected': '可用',
|
||||
'ai.cursor.notFound': '不可用',
|
||||
'ai.cursor.path': '运行环境:',
|
||||
'ai.cursor.notFoundHint': '填写 API Key,或切换到 CLI 登录模式。',
|
||||
'ai.cursor.notInstalledHint': '未检测到 Cursor SDK / Agent CLI。',
|
||||
'ai.cursor.installStatus': 'Cursor 运行时',
|
||||
'ai.cursor.installed': '已检测到',
|
||||
'ai.cursor.notInstalled': '未检测到',
|
||||
'ai.cursor.modeCli': 'CLI 登录',
|
||||
'ai.cursor.modeApiKey': 'API Key',
|
||||
'ai.cursor.modeCliHint': '使用本机 `cursor-agent login` 会话与订阅 Auto 额度。已保存的 API Key 会保留,但此模式不会使用。',
|
||||
'ai.cursor.modeApiKeyHint': '走 Cursor 计量 API。此模式下不会使用 CLI 登录。',
|
||||
'ai.cursor.cliLoginStatus': 'CLI 登录',
|
||||
'ai.cursor.cliLoginOk': '已登录',
|
||||
'ai.cursor.cliLoginAs': '已登录为 {{email}}',
|
||||
'ai.cursor.cliLoginMissing': '未登录',
|
||||
'ai.cursor.cliLoginHint': '在终端运行 `cursor-agent login`,然后点击「检查」。',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': '已填写',
|
||||
'ai.cursor.apiKeyMissing': '未填写',
|
||||
'ai.cursor.apiKeyFromEnv': '来自环境变量',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY;填写后会覆盖',
|
||||
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用,填写保存后会覆盖它。',
|
||||
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key;清空保存后会回到 CURSOR_API_KEY。',
|
||||
'ai.cursor.saveApiKey': '保存',
|
||||
'ai.cursor.saved': '已保存',
|
||||
'ai.cursor.showApiKey': '显示 API Key',
|
||||
'ai.cursor.hideApiKey': '隐藏 API Key',
|
||||
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
|
||||
'ai.cursor.check': '检查',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': '通过官方 Agent SDK(`@tencent-ai/agent-sdk`)接入 CodeBuddy Code。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.codebuddy.detecting': '检测中...',
|
||||
'ai.codebuddy.detected': '已检测到',
|
||||
'ai.codebuddy.notFound': '未找到',
|
||||
'ai.codebuddy.path': '路径:',
|
||||
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': '检查',
|
||||
'ai.codebuddy.resetPath': '重置',
|
||||
'ai.codebuddy.configSection': '认证与配置(可选)',
|
||||
'ai.codebuddy.internetEnv': '网络环境',
|
||||
'ai.codebuddy.internetEnv.default': '默认(海外)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': '设置 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限网络环境请选择 Internal 或 IOA。',
|
||||
'ai.codebuddy.envVars': '环境变量',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
|
||||
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.230)',
|
||||
'ai.codebuddy.effort': '推理力度',
|
||||
'ai.codebuddy.effort.default': '默认',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Low 节省 token,复杂诊断用 High/XHigh。',
|
||||
'ai.codebuddy.maxTurns': '最大轮次',
|
||||
'ai.codebuddy.maxTurns.hint': '限制每次请求的最大对话轮次,防止 AI 失控循环。留空使用默认值。',
|
||||
'ai.codebuddy.maxBudget': '预算上限 (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': '每次请求的最大花费(美元)。超出后自动停止。留空不限制。',
|
||||
'ai.codebuddy.sandbox': '沙箱模式',
|
||||
'ai.codebuddy.sandbox.hint': '在沙箱中执行工具调用,限制文件系统和网络访问。',
|
||||
'ai.codebuddy.fileCheckpointing': '文件检查点',
|
||||
'ai.codebuddy.fileCheckpointing.hint': '启用文件操作检查点,AI 修改文件后可回滚。',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的输入',
|
||||
'ai.codebuddy.elicitation.description': '请确认或填写请求内容以继续当前轮次。',
|
||||
'ai.codebuddy.elicitation.select': '请选择',
|
||||
'ai.codebuddy.elicitation.yes': '是',
|
||||
'ai.codebuddy.elicitation.no': '否',
|
||||
'ai.codebuddy.elicitation.decline': '拒绝',
|
||||
'ai.codebuddy.elicitation.accept': '继续',
|
||||
'ai.codebuddy.elicitation.validation.required': '{field} 为必填项。',
|
||||
'ai.codebuddy.elicitation.validation.invalidType': '{field} 的值无效。',
|
||||
'ai.codebuddy.elicitation.validation.integer': '{field} 必须是整数。',
|
||||
'ai.codebuddy.elicitation.validation.notInteger': '{field} 必须是整数。',
|
||||
'ai.codebuddy.elicitation.validation.minimum': '{field} 不能小于 {limit}。',
|
||||
'ai.codebuddy.elicitation.validation.maximum': '{field} 不能大于 {limit}。',
|
||||
'ai.codebuddy.elicitation.validation.minLength': '{field} 至少需要 {limit} 个字符。',
|
||||
'ai.codebuddy.elicitation.validation.maxLength': '{field} 最多允许 {limit} 个字符。',
|
||||
'ai.codebuddy.elicitation.validation.minItems': '{field} 至少选择 {limit} 项。',
|
||||
'ai.codebuddy.elicitation.validation.maxItems': '{field} 最多选择 {limit} 项。',
|
||||
'ai.codebuddy.elicitation.validation.format': '{field} 必须符合 {format} 格式。',
|
||||
'ai.codebuddy.elicitation.validation.option': '请为 {field} 选择有效选项。',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': '通过官方 SDK 接入 OpenCode。先在 OpenCode 里配置 provider 和密钥,检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.opencode.detecting': '检测中...',
|
||||
'ai.opencode.detected': '已检测到',
|
||||
'ai.opencode.notFound': '未找到',
|
||||
'ai.opencode.path': '路径:',
|
||||
'ai.opencode.notFoundHint': '在 PATH 中未找到 opencode。请安装或在下方指定可执行文件路径。',
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
|
||||
'ai.grok.detecting': '检测中...',
|
||||
'ai.grok.detected': '已检测到',
|
||||
'ai.grok.notFound': '未找到',
|
||||
'ai.grok.path': '路径:',
|
||||
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。请安装 Grok Build CLI 或在下方指定可执行文件路径。',
|
||||
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
|
||||
'ai.grok.check': '检查',
|
||||
'ai.grok.resetPath': '重置',
|
||||
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
|
||||
'ai.grok.runtime.acp.default': '默认',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'通过 Agent Client Protocol(grok agent stdio)接入 Grok。在 session/new 注入 NetMesh MCP。关闭后使用原始 headless streaming-json CLI 路径。',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'当前为 headless streaming-json(grok -p --output-format streaming-json)。MCP 通过项目 .grok/config.toml 注入。',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
'ai.toolAccess.title': '工具接入',
|
||||
'ai.toolAccess.mode': 'NetMesh 接入模式',
|
||||
'ai.toolAccess.description': '选择外部 Agent 访问 NetMesh 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 NetMesh CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.toolAccess.mcpPrompt.title': '喂给 AI 的接入提示词',
|
||||
'ai.toolAccess.mcpPrompt.description': '把这段提示词粘贴到你的 AI 客户端(Codex、Claude Code 等),它就会帮你完成 NetMesh MCP 的注册。',
|
||||
'ai.toolAccess.mcpPrompt.enableHint': '先在下方打开对外 MCP 开关,提示词中才会包含 launcher 路径。',
|
||||
'ai.toolAccess.skills.file': 'Skill 文件',
|
||||
'ai.toolAccess.skills.description': 'Skills + CLI 模式下,Agent 会自动被指向这个本地 Skill 文件;NetMesh CLI 启动路径会在每个会话中提供给 Agent。',
|
||||
'ai.toolAccess.skills.unavailable': '暂时无法获取 Skill 文件路径',
|
||||
|
||||
// External MCP
|
||||
'ai.externalMcp.title': '对外 MCP',
|
||||
'ai.externalMcp.description': '把 NetMesh 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 NetMesh 运行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
'ai.externalMcp.mode.temporary': '临时',
|
||||
'ai.externalMcp.mode.persistent': '常开',
|
||||
'ai.externalMcp.mode.description': '临时模式会在空闲超时后自动关闭;常开模式会在 NetMesh 启动时恢复对外 MCP。',
|
||||
'ai.externalMcp.idleTimeout': '空闲超时',
|
||||
'ai.externalMcp.idleTimeout.description': '临时模式下,若超过该分钟数没有 MCP 操作,将自动关闭对外 MCP。',
|
||||
'ai.externalMcp.idleTimeout.minutes': '分钟',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。',
|
||||
'ai.externalMcp.silentSessions': 'AI 会话静默运行',
|
||||
'ai.externalMcp.silentSessions.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'AI 打开的会话若超过该分钟数没有终端或文件操作,将自动关闭。',
|
||||
'ai.externalMcp.usage.title': '使用说明',
|
||||
'ai.externalMcp.usage.keepRunning': '1. 打开对外 MCP 开关,并保持 NetMesh 运行。',
|
||||
'ai.externalMcp.usage.localhost': '2. 客户端通过本地 launcher 连接(仅 127.0.0.1)。关闭开关后会删除 discovery。',
|
||||
'ai.externalMcp.usage.permissions': '3. 写操作遵循「设置 → AI → 安全」(观察 / 确认 / 自动)以及命令黑名单。',
|
||||
'ai.externalMcp.usage.capabilities': '4. 完整 catalog 工具可用:终端、SFTP、Vault、端口转发。密码 / 私钥不会返回。',
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '写操作权限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:NetMesh 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
|
||||
'ai.externalMcp.permissionMode.unknown': '未知',
|
||||
'ai.externalMcp.discovery': 'Discovery',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': '不可用',
|
||||
'ai.externalMcp.bridgeUnavailable': '对外 MCP 桥接不可用',
|
||||
'ai.externalMcp.copy': '复制',
|
||||
'ai.externalMcp.copied': '已复制',
|
||||
'ai.externalMcp.copyFailed': '复制失败,请手动复制。',
|
||||
'ai.externalMcp.refresh': '刷新',
|
||||
'ai.externalMcp.clientConfiguration': '客户端配置',
|
||||
'ai.externalMcp.clientConfiguration.description': '选择客户端一键安装,或复制 CLI / 配置片段。',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'CLI 命令',
|
||||
'ai.externalMcp.configSnippet': '配置片段',
|
||||
'ai.externalMcp.addToCodex': '添加到 Codex',
|
||||
'ai.externalMcp.addToClaude': '添加到 Claude Code',
|
||||
'ai.externalMcp.addToGrok': '添加到 Grok',
|
||||
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目。请重启 Codex 或打开新会话。',
|
||||
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目。请重启 Claude Code 或打开新会话。',
|
||||
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目。请重启 Grok 或打开新会话。',
|
||||
'ai.externalMcp.installCodex': '请先单独安装 Codex,然后点击刷新。',
|
||||
'ai.externalMcp.installClaude': '请先单独安装 Claude Code,然后点击刷新。',
|
||||
'ai.externalMcp.installGrok': '请先单独安装 Grok CLI,然后点击刷新。',
|
||||
'ai.externalMcp.conflict.description': '已存在指向其他位置的 NetMesh-external 条目,请手动删除或修改。',
|
||||
'ai.externalMcp.enableForLauncher': '请先启用对外 MCP,以获取可用的 launcher 路径。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / 其他客户端',
|
||||
'ai.externalMcp.cursor.description': '合并到 MCP 配置文件(例如 ~/.cursor/mcp.json)。若已有其他服务器,请勿整文件覆盖。',
|
||||
'ai.externalMcp.status.unavailable': '不可用',
|
||||
'ai.externalMcp.status.disabled': '已关闭',
|
||||
'ai.externalMcp.status.running': '运行中',
|
||||
'ai.externalMcp.status.starting': '启动中',
|
||||
'ai.externalMcp.status.error': '错误',
|
||||
'ai.externalMcp.status.configured': '已配置',
|
||||
'ai.externalMcp.status.notConfigured': '未配置',
|
||||
'ai.externalMcp.status.checking': '检查中',
|
||||
'ai.externalMcp.status.codexNotFound': '未找到 Codex',
|
||||
'ai.externalMcp.status.claudeNotFound': '未找到 Claude Code',
|
||||
'ai.externalMcp.status.grokNotFound': '未找到 Grok',
|
||||
'ai.externalMcp.status.conflict': '冲突',
|
||||
'ai.userSkills.title': '用户 Skills',
|
||||
'ai.userSkills.description': '打开 NetMesh 的 Skills 文件夹以添加你自己的技能目录。NetMesh 会自动扫描这些 skills,默认只注入轻量索引,只有在请求明显命中某个 skill 时才展开正文。',
|
||||
'ai.userSkills.openFolder': '打开 Skills 文件夹',
|
||||
'ai.userSkills.reload': '重新加载 Skills',
|
||||
'ai.userSkills.location': '位置',
|
||||
'ai.userSkills.loading': '正在扫描用户 skills...',
|
||||
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
|
||||
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
|
||||
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': '快捷消息',
|
||||
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
|
||||
'ai.quickMessages.add': '添加快捷消息',
|
||||
'ai.quickMessages.createTitle': '新建快捷消息',
|
||||
'ai.quickMessages.editTitle': '编辑快捷消息',
|
||||
'ai.quickMessages.name': '名称',
|
||||
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
|
||||
'ai.quickMessages.slug': '命令',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': '说明(可选)',
|
||||
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
|
||||
'ai.quickMessages.content': '消息内容',
|
||||
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
|
||||
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
|
||||
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
|
||||
'ai.quickMessages.error.nameRequired': '请填写名称。',
|
||||
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
|
||||
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
|
||||
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
|
||||
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': 'Enter 允许 · Esc 拒绝',
|
||||
'ai.chat.approve': '批准',
|
||||
'ai.chat.approveOnce': '允许',
|
||||
'ai.chat.alwaysAllow': '始终',
|
||||
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
|
||||
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.rawCommand': '命令',
|
||||
'ai.chat.copyCommand': '复制',
|
||||
'ai.chat.commandCopied': '已复制',
|
||||
'ai.chat.approvalSession': '会话',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目录',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '调用详情',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
'ai.chat.recommendAllow': '允许',
|
||||
'ai.chat.recommendConfirm': '确认',
|
||||
'ai.chat.recommendDeny': '拒绝',
|
||||
'ai.chat.exportConversation': '导出对话',
|
||||
'ai.chat.exportAs': '导出为',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': '纯文本',
|
||||
'ai.chat.thinking': '思考中',
|
||||
'ai.chat.thoughtFor': '思考了 {duration}',
|
||||
'ai.chat.thought': '思考',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': '在本机检测到',
|
||||
'ai.chat.rescan': '重新扫描',
|
||||
'ai.chat.permObserver': '观察',
|
||||
'ai.chat.permConfirm': '确认',
|
||||
'ai.chat.permAuto': '自动',
|
||||
'ai.chat.permObserverDesc': '只读',
|
||||
'ai.chat.permConfirmDesc': '写操作前确认',
|
||||
'ai.chat.permAutoDesc': '自由执行',
|
||||
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
|
||||
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
|
||||
'ai.chat.noModel': '未选择模型',
|
||||
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
|
||||
'ai.chat.selectProvider': '选择提供商',
|
||||
'ai.chat.selectProviderAndModel': '选择提供商和模型',
|
||||
'ai.chat.selectModel': '选择模型',
|
||||
'ai.chat.searchModels': '搜索模型',
|
||||
'ai.chat.providers': '提供商',
|
||||
'ai.chat.models': '模型',
|
||||
'ai.chat.pinned': '已固定',
|
||||
'ai.chat.useCustomModel': '使用 “{id}”',
|
||||
'ai.chat.thinkingLevel': '思考强度',
|
||||
'ai.chat.thinkingOff': '关闭',
|
||||
'ai.chat.pinModel': '固定模型',
|
||||
'ai.chat.unpinModel': '取消固定',
|
||||
'ai.chat.loadingModels': '正在加载模型...',
|
||||
'ai.chat.noMatchingModels': '没有匹配的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.jumpNav': '跳转到消息',
|
||||
'ai.chat.jumpUntitled': '(空消息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
'ai.chat.menuContext': '上下文',
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuMentionNote': '提及笔记',
|
||||
'ai.chat.mentionNoteSearch': '搜索笔记…',
|
||||
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
|
||||
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
|
||||
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
|
||||
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
|
||||
'ai.chat.untitledNote': '未命名笔记',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
'ai.chat.slashSystemCommands': '系统命令',
|
||||
'ai.chat.slashQuickMessages': '快捷消息',
|
||||
'ai.chat.slashUserSkills': '用户 Skills',
|
||||
'ai.chat.quickMessages': '快捷命令',
|
||||
'ai.chat.slashNoResults': '没有匹配的命令',
|
||||
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
|
||||
|
||||
// AI 聊天快捷入口
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
||||
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 NetMesh 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': '网络搜索',
|
||||
'ai.webSearch.enable': '启用网络搜索',
|
||||
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
|
||||
'ai.webSearch.provider': '搜索供应商',
|
||||
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
|
||||
'ai.webSearch.apiKey': 'API 密钥',
|
||||
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
|
||||
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
|
||||
'ai.webSearch.apiHost': 'API 地址',
|
||||
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL(必填)。',
|
||||
'ai.webSearch.maxResults': '最大结果数',
|
||||
'ai.webSearch.maxResults.description': '搜索返回的最大结果数(1-20)。',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 通过 NetMesh 访问终端会话的方式。观察者模式会阻止经由 NetMesh 的写操作;外部 Agent CLI 可能仍有自己的本机工具和审批流程。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.auto': '自动 - 自由执行',
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '通过 NetMesh 执行命令时允许运行的最长秒数,超时将被终止。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.responseIdleTimeout': '内置 AI 响应等待时间',
|
||||
'ai.safety.responseIdleTimeout.description': '内置 AI 请求连续这么多秒没有收到新响应时会被取消。这个设置不控制整段响应总时长,也不影响命令超时。',
|
||||
'ai.safety.responseIdleTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。外部 Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截通过 NetMesh 执行的危险命令的正则表达式。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'ai.safety.grants.title': '许可记忆表',
|
||||
'ai.safety.grants.heading': 'Confirm 模式放行规则',
|
||||
'ai.safety.grants.description': 'Confirm 模式会先询问再执行;保存为规则后,匹配的同类操作会自动放行。规则对所有终端节点/会话生效,也可手动编辑。',
|
||||
'ai.safety.grants.empty': '尚无规则。可在审批时选择「一律许可」,或手动添加。',
|
||||
'ai.safety.grants.capability': '能力',
|
||||
'ai.safety.grants.sessionPattern': '节点/会话模式',
|
||||
'ai.safety.grants.commandPattern': '命令模式(可选)',
|
||||
'ai.safety.grants.note': '备注(可选)',
|
||||
'ai.safety.grants.add': '添加规则',
|
||||
'ai.safety.grants.remove': '删除',
|
||||
'ai.safety.grants.export': '导出 JSON',
|
||||
'ai.safety.grants.import': '导入 JSON',
|
||||
'ai.safety.note': '这些安全设置会约束经由 NetMesh 执行的操作。外部 Agent CLI 也可能提供本机工具,那部分由 Agent 自己的控制规则约束。',
|
||||
|
||||
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '添加终端',
|
||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||
'terminal.layer.sftp': '文件传输',
|
||||
'terminal.layer.scripts': '脚本',
|
||||
'terminal.layer.history': '命令历史',
|
||||
'terminal.layer.theme': '主题',
|
||||
'terminal.layer.notes': '笔记',
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.closePane': '关闭分屏',
|
||||
'terminal.layer.resizeSplit': '调整分屏大小',
|
||||
'terminal.layer.splitHorizontal': '上下分屏',
|
||||
'terminal.layer.splitVertical': '左右分屏',
|
||||
'terminal.layer.openInNewSplit': '在新分屏中打开',
|
||||
'terminal.layer.hostTree.search': '搜索主机...',
|
||||
'terminal.layer.hostTree.searchButton': '搜索',
|
||||
'terminal.layer.hostTree.tagsButton': '按标签筛选',
|
||||
'terminal.layer.hostTree.newHost': '新建主机',
|
||||
'terminal.layer.hostTree.newHostInGroup': '在此分组中新建主机',
|
||||
'terminal.layer.hostTree.editHost': '编辑主机',
|
||||
'terminal.layer.hostTree.hostSavedNextConnection': '主机已更新,连接设置将在下次连接时生效。',
|
||||
'terminal.layer.hostTree.newGroup': '新建分组',
|
||||
'terminal.layer.hostTree.localShell': '本地 Shell',
|
||||
'terminal.layer.hostTree.tagsEmpty': '暂无标签',
|
||||
'terminal.layer.hostTree.clearTags': '清除筛选',
|
||||
'terminal.layer.hostTree.collapse': '收起主机列表',
|
||||
'terminal.layer.hostTree.expand': '展开主机列表',
|
||||
'terminal.layer.hostTree.empty': '没有匹配的主机',
|
||||
'terminal.layer.hostTree.details.host': '主机',
|
||||
'terminal.layer.hostTree.details.user': '用户',
|
||||
'terminal.layer.hostTree.details.port': '端口',
|
||||
'terminal.layer.hostTree.details.protocol': '协议',
|
||||
'terminal.layer.hostTree.details.group': '分组',
|
||||
'terminal.layer.hostTree.details.tags': '标签',
|
||||
'terminal.layer.hostTree.details.lastConnected': '最近连接',
|
||||
'topTabs.openQuickSwitcher': '打开快速切换',
|
||||
'topTabs.moreTabs': '更多标签页',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
'topTabs.newLocalTerminal': '新建本地终端',
|
||||
'topTabs.controlPanel': '快捷控制',
|
||||
'topTabs.controlPanel.externalMcp': '对外 MCP',
|
||||
'topTabs.controlPanel.theme': '主题',
|
||||
'topTabs.controlPanel.theme.light': '浅色',
|
||||
'topTabs.controlPanel.theme.dark': '深色',
|
||||
'topTabs.controlPanel.theme.system': '系统',
|
||||
'topTabs.externalMcp.enable': '启用对外 MCP',
|
||||
'topTabs.externalMcp.disable': '停用对外 MCP',
|
||||
'topTabs.windowOpacity': '窗口透明度',
|
||||
'topTabs.openSettings': '打开设置',
|
||||
'ai.chat.sessionHistory': '会话历史',
|
||||
'ai.chat.resizeInput': '拖动调整消息输入框高度',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.terminalSelectionAttachment': '终端选区',
|
||||
'ai.chat.terminalSelectionLines': '{count} 行',
|
||||
'ai.chat.collapse': '收起',
|
||||
'ai.chat.expand': '展开',
|
||||
'ai.chat.enableAgent': '启用 {name}',
|
||||
'ai.chat.artifact.noteFallback': 'Vault 笔记',
|
||||
'ai.chat.artifact.openNotes': '打开笔记',
|
||||
'ai.chat.artifact.openHosts': '打开主机',
|
||||
'ai.chat.artifact.notesSummary': 'Vault 中有 {count} 条笔记',
|
||||
'ai.chat.artifact.hostsSummary': 'Vault 中有 {count} 台主机',
|
||||
'ai.chat.artifact.hostsAdded': '已添加 {count} 台主机',
|
||||
'ai.chat.artifact.hostsPreview': '预览 {count} 台主机',
|
||||
'ai.chat.artifact.failed': 'Vault 操作失败',
|
||||
'ai.chat.artifact.unavailableTitle': '不可用',
|
||||
'ai.chat.artifact.noteMissing': '该笔记已不在 Vault 中。',
|
||||
'ai.chat.artifact.hostMissing': '该主机已不在 Vault 中。',
|
||||
'ai.chat.artifact.snippetMissing': '该 Snippet 或脚本已不在 Vault 中。',
|
||||
'ai.chat.artifact.openSnippets': '打开 Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': 'Vault 中有 {count} 个 Snippet',
|
||||
'ai.chat.artifact.scriptsSummary': 'Vault 中有 {count} 个脚本',
|
||||
'ai.chat.artifact.snippetFallback': 'Vault Snippet',
|
||||
'ai.chat.artifact.scriptFallback': '自动化脚本',
|
||||
'ai.chat.artifact.scriptLanguage': '{language} 脚本',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet 已删除',
|
||||
'ai.chat.artifact.scriptDeleted': '脚本已删除',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet 已执行',
|
||||
'ai.chat.artifact.scriptStarted': '脚本运行已启动',
|
||||
'ai.chat.artifact.scriptRunStatus': '脚本运行 {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} 个脚本运行',
|
||||
'ai.chat.artifact.scriptRunStopped': '脚本运行已停止',
|
||||
'ai.chat.artifact.scriptRunPaused': '脚本运行已暂停',
|
||||
'ai.chat.artifact.scriptRunResumed': '脚本运行已恢复',
|
||||
'ai.chat.artifact.scriptReference': 'nct API 参考',
|
||||
'zmodem.waitingForRemote': '等待远端...',
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '远端已存在同名文件',
|
||||
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
|
||||
'zmodem.overwrite.overwrite': '覆盖',
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
};
|
||||
1136
application/i18n/locales/zh-CN/core.ts
Normal file
1136
application/i18n/locales/zh-CN/core.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user